diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..92984a15 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,196 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + format-check: + name: Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9.0.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run format check + run: pnpm format:check + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9.0.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run lint + run: pnpm lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9.0.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run type check + run: pnpm typecheck + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9.0.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run build + run: pnpm build + + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9.0.0 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test diff --git a/.gitignore b/.gitignore index 16d5d6f5..e5fff875 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules dist dist-ssr *.local +lib +lib-commonjs # Editor directories and files .vscode/* @@ -34,3 +36,6 @@ test-results/ # Temporary files (logs, sessions, feedback) temp/ + +# Generated CSS module type definitions +*.css.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..bec9be53 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +strict-peer-dependencies=false +auto-install-peers=true +shamefully-hoist=true +prefer-workspace-packages=true \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..742b636d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +**/node_modules + +# Build outputs +dist/ +build/ +lib/ +coverage/ +.turbo/ + +# Lock files +pnpm-lock.yaml +package-lock.json +yarn.lock + +# Generated files +*.d.ts +*.d.ts.map +*.js.map + +# Temp folders +temp/ +tmp/ +.cache/ + +# Logs +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Test artifacts +playwright-report/ +test-results/ + +# Specific files to ignore +**/*.min.js +**/*.min.css \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c9186140 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.tcmrc.json b/.tcmrc.json new file mode 100644 index 00000000..f6076425 --- /dev/null +++ b/.tcmrc.json @@ -0,0 +1,5 @@ +{ + "pattern": "**/*.module.css", + "nameFormat": "camel", + "exportType": "default" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..15b43978 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,211 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Claude Flow is a modern project management platform with AI-powered features, organized as a monorepo using pnpm workspaces. The project consists of a V1 application (React + Express) with plans for gradual migration to V2 architecture. + +## Common Commands + +### Development + +```bash +# Install dependencies (run from root) +pnpm install + +# Start development (interactive menu) +pnpm dev + +# Start V1 development directly +pnpm dev:v1 +# This starts: +# - Frontend: http://localhost:5173 +# - Backend: http://localhost:3001 + +# Build entire monorepo +pnpm build + +# Build V1 client only +pnpm build:v1 +``` + +### Testing + +```bash +# Run all tests (orchestrated with Lage) +pnpm test + +# Run V1 e2e tests +pnpm test:e2e + +# Run V1 e2e tests with UI mode +pnpm test:e2e:ui + +# Run a specific e2e test +cd apps/v1/client && pnpm playwright test +``` + +### Code Quality + +```bash +# Lint all packages +pnpm lint + +# Lint with auto-fix +pnpm lint:fix + +# TypeScript type checking +pnpm typecheck + +# Run comprehensive check (build, test, lint) +pnpm check +``` + +### Utilities + +```bash +# Create new package from template +pnpm scaffold + +# Clean build artifacts +pnpm clean +``` + +## High-Level Architecture + +### Monorepo Structure + +``` +/ +├── apps/ +│ ├── v1/ +│ │ ├── client/ # React 19 + Vite frontend +│ │ └── server/ # Express v5 backend +│ └── v2/ # Future V2 applications +├── packages/ # Shared packages +├── tools/ +│ └── repo-scripts/ # CLI development tools +├── docs/ # Architecture documentation +└── temp/ # Logs, sessions, feedback +``` + +### Key Technologies + +- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS, React Router v7 +- **Backend**: Express v5, Claude Code SDK, file-based storage +- **Testing**: Playwright (e2e), configured for headless Chromium +- **Build**: pnpm workspaces, Lage orchestration, TypeScript project references +- **Development**: Hot reload for frontend, manual restart required for server + +### Context-Based State Management + +The application uses a hierarchical context provider structure: + +``` +AppProvider → ThemeProvider → ToastProvider → AuthProvider → +GitHubProvider → WorkspaceProvider → LayoutProvider → SubscriptionProvider +``` + +Key contexts: + +- **AppContext**: Core state (projects, work items, personas) +- **WorkspaceContext**: Workspace management with caching +- **ClaudeCodeContext**: Claude API session management +- **SubscriptionContext**: Real-time updates via EventSource/WebSocket + +### Workspace-Driven Architecture + +The application requires workspace selection before functionality is available. Workspaces sync with the backend file system, and projects/work items are discovered from markdown files. + +### Component Organization + +- `/components/ui/`: Reusable UI primitives +- `/components/chat/`: Chat functionality +- `/components/claude-code/`: Claude Code integration +- `/components/dialogs/`: Dialog components (no native browser dialogs) + +### Data Flow Patterns + +- Client-side caching with `getCached` utility +- Progressive loading (light data first) +- Optimistic UI updates +- Real-time sync via subscriptions +- File system as database on server + +### Server Architecture + +- Express middleware stack +- In-memory caching with TTL +- Session persistence in `/temp/sessions/` +- Claude API integration service +- Feedback system with screenshot capture + +## Important Development Notes + +### Server Restart Required + +**CRITICAL**: When modifying ANY server files (`/apps/v1/server/*.js`), you MUST manually restart the server. The development server does not auto-restart. Always remind users: **"Please restart your server to apply these changes"** + +### Dialog Patterns + +- **NEVER** use `alert()`, `confirm()`, or `prompt()` +- Always use React dialog components (e.g., `ConfirmDialog`) +- Maintain consistent dialog UI/UX patterns + +### File Operations + +- Use absolute paths in all file operations +- Workspace data stored in file system +- Feedback stored in `/temp/feedback/` +- Sessions stored in `/temp/sessions/` + +### Testing Practices + +- E2E tests use Playwright with headless Chromium +- Tests are in `/apps/v1/client/e2e/` +- Mock data supported for consistent testing +- Video/screenshot capture on failure + +### Code Style + +- TypeScript for type safety (avoid `any`) +- No CSS-in-JS (use CSS modules or Tailwind) +- Functional components with hooks +- Type imports: `import type { Foo }` or `import { type Foo, Bar }` + +### Git Workflow + +- Feature branches: `feature/`, `fix/`, `chore/` +- No AI attribution in commits or code +- Run tests and lint before committing + +## Architecture Principles + +1. **Context Providers** over prop drilling +2. **Lazy Loading** for performance +3. **File System as Database** for workspace data +4. **Real-time First** design +5. **Progressive Enhancement** for UX +6. **Type Safety** throughout + +## Feedback Processing + +When processing feedback: + +1. Check `/temp/feedback/reports/` for feedback files +2. Analyze with corresponding screenshots +3. Fix root causes, not symptoms +4. Add tests to prevent regression +5. Document in `/temp/feedback/addressed/` +6. Compact context between files + +## Migration Strategy + +The project follows a phased migration from V1 to V2: + +- Phase 0-1: ✅ Repository restructure complete +- Phase 2: 🚧 Core package development +- Phase 3-5: Future V2 development and migration + +See `/docs/guides/migration/migration-v1-to-v2.md` for details. diff --git a/README.md b/README.md index 2ceeefa7..10738768 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,129 @@ -# Project Management UX +# Claude Flow Monorepo -A modern project management interface with AI-powered work item creation. +A modern project management platform with AI-powered features, built as a monorepo to support gradual migration from v1 to v2 architecture. -## Features +## Overview -- **AI-Powered Work Item Creation**: Describe your idea and let Claude break it down into actionable tasks -- **Multi-step Creation Process**: Clean, focused UI for creating work items -- **Master-Detail Task View**: Review and refine generated tasks before creating work items -- **Theme Support**: Multiple beautiful themes to choose from -- **Responsive Design**: Works great on desktop and mobile +This monorepo contains: -## Getting Started +- **V1 Application** - Current production application (React + Express) +- **V2 Packages** - Next-generation shared packages (coming soon) +- **Development Tools** - CLI tools and shared configurations -### Prerequisites +## Quick Start -- Node.js 18+ -- npm or yarn +```bash +# Install dependencies +pnpm install -### Installation +# Start development (interactive menu) +pnpm dev -1. Clone the repository -2. Install dependencies: - ```bash - npm install - ``` +# Run specific commands +pnpm build # Build all packages +pnpm test # Run all tests +pnpm lint # Lint all packages +``` -3. Start the development server: - ```bash - npm run dev - ``` +## Repository Structure -### Running with AI Features +``` +/ +├── apps/ +│ ├── v1/ # Current production application +│ │ ├── client/ # React frontend +│ │ └── server/ # Express backend +│ └── v2/ # Future v2 applications +├── packages/ # Shared packages +│ ├── tsconfig/ # Shared TypeScript configurations +│ └── eslint-config/ # Shared ESLint configurations +├── tools/ +│ └── repo-scripts/ # CLI development tools +├── docs/ # Documentation +│ ├── getting-started.md # Developer onboarding +│ └── guides/ # Development guides +└── temp/ # Temporary files (logs, sessions) +``` -To use the AI-powered work item creation, you need to run both the frontend and the mock server. +## Development + +### Prerequisites + +- Node.js 18+ +- pnpm 9.0.0+ (will be auto-installed via corepack) -#### Option 1: Using the start script (Recommended) +### Running V1 Application ```bash -./start-dev.sh +# Interactive menu +pnpm dev +# Select "V1 Application (port 3000)" + +# Or directly +pnpm dev:v1 ``` -This will start both servers automatically. +This starts: -#### Option 2: Manual setup +- Frontend: http://localhost:5173 +- Backend: http://localhost:3001 -1. **Terminal 1 - Start the mock server:** - ```bash - cd server - npm install # First time only - npm run mock - ``` - The server will run on http://localhost:3000 +### Available Scripts -2. **Terminal 2 - Start the frontend:** - ```bash - npm run dev - ``` - The frontend will run on http://localhost:5173 +| Command | Description | +| ---------------- | -------------------------------- | +| `pnpm dev` | Interactive development menu | +| `pnpm build` | Build all packages | +| `pnpm test` | Run all tests | +| `pnpm lint` | Lint all packages | +| `pnpm typecheck` | TypeScript type checking | +| `pnpm scaffold` | Create new package from template | -3. Navigate to Work Items and click "Create with AI" +### V1-Specific Scripts -#### Using real Claude integration +| Command | Description | +| --------------- | ---------------------------- | +| `pnpm dev:v1` | Start v1 development servers | +| `pnpm build:v1` | Build v1 for production | +| `pnpm lint:v1` | Lint v1 code | +| `pnpm test:e2e` | Run v1 e2e tests | -Instead of the mock server, you can use real Claude: -```bash -cd server -cp .env.example .env -# Add your ANTHROPIC_API_KEY to .env -npm start -``` +## Architecture -#### Troubleshooting +This monorepo uses: -If you get "Failed to fetch" errors: -- Ensure the mock server is running on port 3000 -- Check that no other service is using port 3000 -- Try accessing http://localhost:3000/api/health directly +- **pnpm** - Fast, efficient package manager with workspace support +- **Lage** - Build orchestration for monorepos +- **TypeScript** - With project references for fast builds +- **Shared Configurations** - Consistent TypeScript and ESLint settings -## Development +## Migration Strategy -### Project Structure +We're following a gradual migration approach from v1 to v2: -``` -src/ -├── components/ # Reusable UI components -├── contexts/ # React contexts (App, Theme) -├── pages/ # Page components -├── types/ # TypeScript type definitions -└── hooks/ # Custom React hooks - -server/ -├── index.js # Real Claude integration server -├── mock-server.js # Mock server for testing -└── README.md # Server documentation -``` +1. **Phase 0** ✅ - Repository restructure (complete) +2. **Phase 1** ✅ - Infrastructure setup (complete) +3. **Phase 2** 🚧 - Core package development +4. **Phase 3** - V2 application development +5. **Phase 4** - Routing & integration +6. **Phase 5** - Gradual user migration -### Available Scripts +See [migration guide](docs/guides/migration/migration-v1-to-v2.md) for details. + +## Documentation + +- [Getting Started](docs/getting-started.md) - New developer onboarding +- [Development Workflow](docs/guides/development/development-workflow.md) - Daily development practices +- [Architecture Decisions](docs/guides/architecture/architecture-decisions.md) - Key design choices +- [V1 Application](apps/v1/README.md) - V1-specific documentation + +## Contributing -- `npm run dev` - Start development server -- `npm run build` - Build for production -- `npm run lint` - Run ESLint -- `npm run preview` - Preview production build +1. Create a feature branch +2. Make your changes +3. Run `pnpm lint` and `pnpm test` +4. Submit a pull request -### Technologies +## License -- React 19 -- TypeScript -- Vite -- Tailwind CSS -- React Router -- Express (server) -- Claude Code SDK \ No newline at end of file +[License information here] diff --git a/apps/v1/README.md b/apps/v1/README.md new file mode 100644 index 00000000..855b2f65 --- /dev/null +++ b/apps/v1/README.md @@ -0,0 +1,130 @@ +# Claude Flow + +A modern project management interface with AI-powered work item creation. + +## Features + +- **AI-Powered Work Item Creation**: Describe your idea and let Claude break it down into actionable tasks +- **Multi-step Creation Process**: Clean, focused UI for creating work items +- **Master-Detail Task View**: Review and refine generated tasks before creating work items +- **Theme Support**: Multiple beautiful themes to choose from +- **Responsive Design**: Works great on desktop and mobile + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Installation + +1. Clone the repository +2. Install dependencies: + + ```bash + npm install + ``` + +3. Start the development server: + ```bash + npm run dev + ``` + +### Running with AI Features + +To use the AI-powered work item creation, you need to run both the frontend and the mock server. + +#### Option 1: From the root directory (Recommended) + +```bash +# From project root +pnpm dev +# Then select "V1 Application" +``` + +This will start both servers automatically. + +#### Option 2: Manual setup + +1. **Terminal 1 - Start the mock server:** + + ```bash + cd server + npm install # First time only + npm run mock + ``` + + The server will run on http://localhost:3000 + +2. **Terminal 2 - Start the frontend:** + + ```bash + cd client + npm run dev + ``` + + The frontend will run on http://localhost:5173 + +3. Navigate to Work Items and click "Create with AI" + +#### Using real Claude integration + +Instead of the mock server, you can use real Claude: + +```bash +cd server +cp .env.example .env +# Add your ANTHROPIC_API_KEY to .env +npm start +``` + +#### Troubleshooting + +If you get "Failed to fetch" errors: + +- Ensure the mock server is running on port 3000 +- Check that no other service is using port 3000 +- Try accessing http://localhost:3000/api/health directly + +## Development + +### Project Structure + +``` +src/ +├── components/ # Reusable UI components +├── contexts/ # React contexts (App, Theme) +├── pages/ # Page components +├── types/ # TypeScript type definitions +└── hooks/ # Custom React hooks + +server/ +├── index.js # Real Claude integration server +├── mock-server.js # Mock server for testing +└── README.md # Server documentation +``` + +### Available Scripts + +From the monorepo root: + +- `pnpm dev` - Start both client and server +- `pnpm build:v1` - Build v1 for production +- `pnpm lint:v1` - Run ESLint on v1 +- `pnpm test:e2e` - Run e2e tests + +From this directory (`apps/v1`): + +- Client scripts are in `client/package.json` +- Server scripts are in `server/package.json` + +### Technologies + +- React 19 +- TypeScript +- Vite +- Tailwind CSS +- React Router +- Express (server) +- Claude Code SDK diff --git a/CSS_CONVENTIONS.md b/apps/v1/client/CSS_CONVENTIONS.md similarity index 94% rename from CSS_CONVENTIONS.md rename to apps/v1/client/CSS_CONVENTIONS.md index 4678c8fc..d618d3ef 100644 --- a/CSS_CONVENTIONS.md +++ b/apps/v1/client/CSS_CONVENTIONS.md @@ -52,6 +52,7 @@ z-[9999] /* Critical system dialogs (feedback, errors) */ ### Common patterns #### Modals and dialogs + ```tsx // Standard dialog
@@ -67,21 +68,19 @@ z-[9999] /* Critical system dialogs (feedback, errors) */ ``` #### Dropdown menus + ```tsx
-
- {/* Dropdown content */} -
+
{/* Dropdown content */}
``` #### Fixed position elements + ```tsx // Toast notifications -
- {/* Toast content */} -
+
{/* Toast content */}
``` ### Debugging z-index issues @@ -96,6 +95,7 @@ If you encounter layering issues: ### Future considerations As the application grows, we may need to: + - Add more granular levels between existing values - Create component-specific z-index variables -- Implement a z-index management system for complex nested components \ No newline at end of file +- Implement a z-index management system for complex nested components diff --git a/Z_INDEX_GUIDELINES.md b/apps/v1/client/Z_INDEX_GUIDELINES.md similarity index 96% rename from Z_INDEX_GUIDELINES.md rename to apps/v1/client/Z_INDEX_GUIDELINES.md index c12fecf4..230b0486 100644 --- a/Z_INDEX_GUIDELINES.md +++ b/apps/v1/client/Z_INDEX_GUIDELINES.md @@ -44,7 +44,7 @@ When z-index IS needed, use this consistent scale: - `z-10` - Sticky elements (headers, footers) -- `z-20` - Floating UI (tooltips, popovers) +- `z-20` - Floating UI (tooltips, popovers) - `z-30` - Overlays (dropdown menus) - `z-40` - Modal backdrops - `z-50` - Modal content @@ -64,4 +64,4 @@ If content appears behind something it shouldn't: 3. Look for parent elements creating new stacking contexts 4. Use browser DevTools to inspect computed z-index values -Remember: Most layout issues can be solved without z-index through proper DOM ordering and positioning. \ No newline at end of file +Remember: Most layout issues can be solved without z-index through proper DOM ordering and positioning. diff --git a/e2e/breadcrumb-navigation.spec.ts b/apps/v1/client/e2e/breadcrumb-navigation.spec.ts similarity index 97% rename from e2e/breadcrumb-navigation.spec.ts rename to apps/v1/client/e2e/breadcrumb-navigation.spec.ts index c57d4e0b..a65d91b2 100644 --- a/e2e/breadcrumb-navigation.spec.ts +++ b/apps/v1/client/e2e/breadcrumb-navigation.spec.ts @@ -3,11 +3,11 @@ import { test, expect, setupWorkspaceInBrowser } from './test-setup'; test.describe('Breadcrumb Navigation', () => { test('should navigate through breadcrumbs correctly', async ({ page, testWorkspace }) => { await setupWorkspaceInBrowser(page, testWorkspace); - + // Step 1: Start at Projects page - should have no breadcrumb // The setup already waits for project-card, so just take screenshot await page.screenshot({ path: 'test-results/breadcrumb-1-projects-page.png' }); - + // Check that no breadcrumb is visible on Projects page const breadcrumbLocator = page.locator('nav[aria-label="Breadcrumb"]'); const breadcrumbCount = await breadcrumbLocator.count(); @@ -15,29 +15,29 @@ test.describe('Breadcrumb Navigation', () => { const breadcrumbText = await breadcrumbLocator.first().textContent(); expect(breadcrumbText?.trim()).toBeFalsy(); // Should be empty or not visible } - + // Step 2: Navigate to project detail page // Look for the test project card - it should have "Test Project" text const projectCards = page.locator('[data-testid="project-card"]'); const projectCard = projectCards.first(); // In test setup, we only have one project await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); - + // Wait for navigation to complete - look for repo cards await page.waitForURL('**/projects/**'); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Wait a bit for animations to complete await page.waitForTimeout(100); - + // Check breadcrumb shows: Projects > test-project await expect(breadcrumbLocator.first()).toBeVisible({ timeout: 5000 }); await page.screenshot({ path: 'test-results/breadcrumb-2-project-detail.png' }); - + const projectBreadcrumbText = await breadcrumbLocator.first().textContent(); expect(projectBreadcrumbText).toContain('Projects'); expect(projectBreadcrumbText).toContain('test-project'); - + // Step 3: Navigate to Claude Code // First dismiss any toasts that might be blocking const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); @@ -45,29 +45,29 @@ test.describe('Breadcrumb Navigation', () => { if (toastCount > 0) { // Try to click the close button if available, or wait for toast to disappear const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } // Wait a bit for toast to disappear await page.waitForTimeout(1000); } - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 5000 }); const repoName = await claudeCodeButton.getAttribute('data-repo-name'); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - + // Wait a bit for animations to complete await page.waitForTimeout(100); - + // Wait for breadcrumb to update with Claude Code await expect(breadcrumbLocator.first()).toContainText('Claude Code', { timeout: 5000 }); await page.screenshot({ path: 'test-results/breadcrumb-3-claude-code.png' }); - + // Check breadcrumb shows: test-project > [repo-name] > Claude Code const claudeBreadcrumbText = await breadcrumbLocator.first().textContent(); expect(claudeBreadcrumbText).toContain('test-project'); @@ -75,39 +75,39 @@ test.describe('Breadcrumb Navigation', () => { if (repoName) { expect(claudeBreadcrumbText).toContain(repoName); } - + // Step 4: Navigate back to project detail await page.goBack(); await page.waitForURL('**/projects/**'); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Wait a bit for animations to complete await page.waitForTimeout(100); - + // Verify breadcrumb reverts correctly await expect(breadcrumbLocator.first()).not.toContainText('Claude Code', { timeout: 5000 }); await page.screenshot({ path: 'test-results/breadcrumb-4-back-to-project.png' }); - + const backBreadcrumbText = await breadcrumbLocator.first().textContent(); expect(backBreadcrumbText).toContain('Projects'); expect(backBreadcrumbText).toContain('test-project'); expect(backBreadcrumbText).not.toContain('Claude Code'); - + // Step 5: Navigate back to Projects page await page.goBack(); await page.waitForURL('**/projects'); await page.waitForSelector('[data-testid="project-card"]', { timeout: 5000 }); - + // Wait a bit for animations to complete await page.waitForTimeout(100); - + // Check that breadcrumb is cleared on Projects page await page.screenshot({ path: 'test-results/breadcrumb-5-back-to-projects.png' }); - + const finalBreadcrumbCount = await breadcrumbLocator.count(); if (finalBreadcrumbCount > 0) { const finalBreadcrumbText = await breadcrumbLocator.first().textContent(); expect(finalBreadcrumbText?.trim()).toBeFalsy(); // Should be empty } }); -}); \ No newline at end of file +}); diff --git a/e2e/claude-code-mount-fix.spec.ts b/apps/v1/client/e2e/claude-code-mount-fix.spec.ts similarity index 80% rename from e2e/claude-code-mount-fix.spec.ts rename to apps/v1/client/e2e/claude-code-mount-fix.spec.ts index b8e61920..d3f1ea70 100644 --- a/e2e/claude-code-mount-fix.spec.ts +++ b/apps/v1/client/e2e/claude-code-mount-fix.spec.ts @@ -1,104 +1,115 @@ import { test, expect, setupWorkspaceInBrowser } from './test-setup'; test.describe('Claude Code Mount Fix Validation', () => { - test('should properly handle mount state when setting up SSE connection', async ({ page, testWorkspace }) => { + test('should properly handle mount state when setting up SSE connection', async ({ + page, + testWorkspace, + }) => { const consoleLogs: string[] = []; - + // Capture console logs page.on('console', (msg) => { consoleLogs.push(msg.text()); }); - + // Set up workspace and navigate to Claude Code await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const testProject = page.locator('[data-testid="project-card"]').first(); await expect(testProject).toBeVisible({ timeout: 10000 }); await testProject.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { // Click somewhere else to close it await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + // Clear logs before the critical action consoleLogs.length = 0; await claudeCodeButton.click(); - + // Wait for component to initialize await expect(page.locator('text=Claude Code Session')).toBeVisible({ timeout: 30000 }); - + // Wait a bit to collect all logs await page.waitForTimeout(2000); - + // Check for the specific error that was fixed - const hasUnmountError = consoleLogs.some(log => + const hasUnmountError = consoleLogs.some((log) => log.includes('Component unmounted, skipping SSE setup') ); - + // Check for successful SSE setup - const hasSuccessfulSetup = consoleLogs.some(log => + const hasSuccessfulSetup = consoleLogs.some((log) => log.includes('Setting up delayed SSE connection') ); - + // Check for mount/unmount sequence - const mountLogs = consoleLogs.filter(log => - log.includes('MOUNTED') || log.includes('UNMOUNTING') + const mountLogs = consoleLogs.filter( + (log) => log.includes('MOUNTED') || log.includes('UNMOUNTING') ); - + console.log('Mount/Unmount sequence:', mountLogs); - + // Assertions expect(hasUnmountError).toBe(false); // The bug should not occur expect(hasSuccessfulSetup).toBe(true); // SSE should be set up successfully - + // The component should not unmount within the 500ms delay const unmountDuringDelay = consoleLogs.some((log, index) => { if (log.includes('Scheduling SSE connection setup with delay')) { // Check if unmount happens within next few logs const nextLogs = consoleLogs.slice(index, index + 10); - return nextLogs.some(l => l.includes('UNMOUNTING')); + return nextLogs.some((l) => l.includes('UNMOUNTING')); } return false; }); - + expect(unmountDuringDelay).toBe(false); // Component should stay mounted during delay - + // Wait for greeting message to confirm full functionality let greetingFound = false; const maxWaitTime = 15000; const startTime = Date.now(); - - while (!greetingFound && (Date.now() - startTime) < maxWaitTime) { + + while (!greetingFound && Date.now() - startTime < maxWaitTime) { const bodyText = await page.textContent('body'); - if (bodyText && (bodyText.includes('Hello') || bodyText.includes('Hi') || bodyText.includes('Hey') || bodyText.includes('Welcome') || bodyText.includes('help you') || bodyText.includes('Great to see you'))) { + if ( + bodyText && + (bodyText.includes('Hello') || + bodyText.includes('Hi') || + bodyText.includes('Hey') || + bodyText.includes('Welcome') || + bodyText.includes('help you') || + bodyText.includes('Great to see you')) + ) { greetingFound = true; break; } await page.waitForTimeout(500); } - + // For test workspaces, we might not get a full greeting, so just check that we got some content expect(greetingFound).toBe(true); // Should have some content - + console.log('✅ All assertions passed! The mount fix is working correctly.'); }); -}); \ No newline at end of file +}); diff --git a/e2e/claude-code-mount-issue.spec.ts b/apps/v1/client/e2e/claude-code-mount-issue.spec.ts similarity index 83% rename from e2e/claude-code-mount-issue.spec.ts rename to apps/v1/client/e2e/claude-code-mount-issue.spec.ts index f9372059..bbeffd73 100644 --- a/e2e/claude-code-mount-issue.spec.ts +++ b/apps/v1/client/e2e/claude-code-mount-issue.spec.ts @@ -7,20 +7,23 @@ import { promisify } from 'util'; const execAsync = promisify(exec); test.describe('Claude Code Mount/Unmount Issue', () => { - test('should capture console logs when navigating to Claude Code', async ({ page, testWorkspace }) => { + test('should capture console logs when navigating to Claude Code', async ({ + page, + testWorkspace, + }) => { // Clear any existing logs const logPath = path.join(process.cwd(), 'e2e-console-logs.txt'); await fs.writeFile(logPath, ''); - + // Array to capture console logs const consoleLogs: string[] = []; - + // Listen to console events page.on('console', (msg) => { const text = `[${msg.type()}] ${msg.text()}`; consoleLogs.push(text); }); - + // Clear session folder (if it exists) const sessionPath = path.join(process.cwd(), 'server/sessions'); try { @@ -29,126 +32,129 @@ test.describe('Claude Code Mount/Unmount Issue', () => { } catch (error) { // Ignore if folder doesn't exist } - + console.log('Starting test: setting up workspace...'); - + // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + console.log('Workspace setup complete, looking for test project...'); - + // Look for test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); - + // Click on the project await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + console.log('Project detail page loaded, looking for Claude Code button...'); - + // Look for Claude Code button and click it const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { // Click somewhere else to close it await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + // Clear logs right before clicking to focus on the mount/unmount issue consoleLogs.length = 0; consoleLogs.push('=== CLAUDE CODE BUTTON CLICKED ==='); - + await claudeCodeButton.click(); - + console.log('Claude Code button clicked, waiting for UI...'); - + // Wait for Claude Code UI to appear await expect(page.locator('text=Claude Code Session')).toBeVisible({ timeout: 30000 }); - + console.log('Claude Code UI visible, waiting for greeting...'); - + // Wait a bit more to capture all mount/unmount cycles and greeting await page.waitForTimeout(10000); - + // Check if chat bubble shows progress or message const chatContent = page.locator('.flex-1.overflow-auto'); - const hasContent = await chatContent.locator('div').count() > 0; - + const hasContent = (await chatContent.locator('div').count()) > 0; + console.log('Chat has content:', hasContent); - + // Write all console logs to file const logContent = consoleLogs.join('\n'); await fs.writeFile(logPath, logContent); - + console.log(`Console logs written to: ${logPath}`); - + // Also capture server logs const serverLogsPath = path.join(process.cwd(), 'server/logs'); const claudeLogPath = path.join(serverLogsPath, 'claude-messages.log'); const eventsLogPath = path.join(serverLogsPath, 'events.log'); - + try { const claudeLog = await fs.readFile(claudeLogPath, 'utf-8'); const eventsLog = await fs.readFile(eventsLogPath, 'utf-8'); - + // Get last 50 lines of each log const claudeLines = claudeLog.split('\n').slice(-50).join('\n'); const eventsLines = eventsLog.split('\n').slice(-50).join('\n'); - + await fs.writeFile( path.join(process.cwd(), 'e2e-server-logs.txt'), `=== CLAUDE MESSAGES LOG (last 50 lines) ===\n${claudeLines}\n\n=== EVENTS LOG (last 50 lines) ===\n${eventsLines}` ); - + console.log('Server logs captured to: e2e-server-logs.txt'); } catch (error) { console.error('Failed to capture server logs:', error); } - + // Analyze the logs for mount/unmount patterns - const mountPatterns = consoleLogs.filter(log => - log.includes('[ClaudeCodeProvider] MOUNTED') || - log.includes('[ClaudeCodeProvider] UNMOUNTING') || - log.includes('[ClaudeCode] Component rendering') || - log.includes('SSE connection') || - log.includes('message-start event') || - log.includes('message-chunk event') || - log.includes('Message not found for chunk') + const mountPatterns = consoleLogs.filter( + (log) => + log.includes('[ClaudeCodeProvider] MOUNTED') || + log.includes('[ClaudeCodeProvider] UNMOUNTING') || + log.includes('[ClaudeCode] Component rendering') || + log.includes('SSE connection') || + log.includes('message-start event') || + log.includes('message-chunk event') || + log.includes('Message not found for chunk') ); - + console.log('\n=== ANALYSIS ==='); console.log('Mount/Unmount patterns found:'); - mountPatterns.forEach(log => console.log(log)); - + mountPatterns.forEach((log) => console.log(log)); + // Check for the specific issue pattern - const hasMultipleMounts = mountPatterns.filter(log => log.includes('MOUNTED')).length > 1; - const hasMessageNotFound = consoleLogs.some(log => log.includes('Message not found for chunk')); - + const hasMultipleMounts = mountPatterns.filter((log) => log.includes('MOUNTED')).length > 1; + const hasMessageNotFound = consoleLogs.some((log) => + log.includes('Message not found for chunk') + ); + if (hasMultipleMounts) { console.log('\n⚠️ ISSUE DETECTED: Multiple mount cycles detected!'); } - + if (hasMessageNotFound) { console.log('\n⚠️ ISSUE DETECTED: Message not found for chunk error!'); } - + // The test should complete even if issues are found // This allows us to analyze the logs expect(true).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/e2e/claude-code-tool-execution.spec.ts b/apps/v1/client/e2e/claude-code-tool-execution.spec.ts similarity index 93% rename from e2e/claude-code-tool-execution.spec.ts rename to apps/v1/client/e2e/claude-code-tool-execution.spec.ts index 27d25e66..68a3fb80 100644 --- a/e2e/claude-code-tool-execution.spec.ts +++ b/apps/v1/client/e2e/claude-code-tool-execution.spec.ts @@ -1,68 +1,71 @@ import { test, expect } from './test-setup'; test.describe('Claude Code Tool Execution Display', () => { - test('should display tool executions correctly without false failures', async ({ page, testWorkspace }) => { + test('should display tool executions correctly without false failures', async ({ + page, + testWorkspace, + }) => { console.log('Test workspace:', testWorkspace); - + // Navigate to the test project await page.click('[data-testid="project-card"]'); await page.waitForURL('**/projects/*'); - + // Enter Claude Code for a repo await page.click('[data-testid="claude-code-button"]'); await page.waitForURL('**/claude-code/*'); - + // Wait for Claude Code interface to load await page.waitForSelector('[data-testid="claude-code-input"]', { timeout: 10000 }); - + // Wait for greeting message to complete await page.waitForSelector('[data-testid="message-complete"]', { timeout: 30000 }); - + // Type a message that will trigger tool use const input = page.locator('[data-testid="claude-code-input"]'); await input.fill('Read the README.md file and summarize it'); - + // Press Enter to send the message await input.press('Enter'); - + // Wait for the response to start await page.waitForSelector('[data-testid="message-start"]', { timeout: 10000 }); - + // Check for tool execution display const toolExecution = page.locator('[data-testid="tool-execution"]').first(); await expect(toolExecution).toBeVisible({ timeout: 5000 }); - + // Verify the tool execution shows correct information const toolName = toolExecution.locator('[data-testid="tool-name"]'); await expect(toolName).toContainText('Read'); - + // Check that the tool status is not showing as error initially const toolStatus = toolExecution.locator('[data-testid="tool-status"]'); - + // Tool should show as pending or completed, not failed const statusText = await toolStatus.textContent(); expect(statusText).not.toContain('Failed'); expect(statusText).not.toContain('Error'); - + // The status should be either "Pending..." or show success expect(statusText).toMatch(/Pending\.\.\.|Success|Completed/i); - + // Wait for the message to complete await page.waitForSelector('[data-testid="message-complete"]', { timeout: 30000 }); - + // Verify the summary content was returned const messageContent = page.locator('[data-testid="message-content"]').last(); await expect(messageContent).toContainText('Hello World Project'); - + // Final check: tool execution should not show as failed const finalToolStatus = await toolStatus.textContent(); expect(finalToolStatus).not.toContain('Failed'); expect(finalToolStatus).not.toContain('Tool execution failed'); - + // Take a screenshot for debugging - await page.screenshot({ + await page.screenshot({ path: 'claude-code-tool-execution.png', - fullPage: true + fullPage: true, }); }); -}); \ No newline at end of file +}); diff --git a/e2e/claude-code-validation.spec.ts b/apps/v1/client/e2e/claude-code-validation.spec.ts similarity index 77% rename from e2e/claude-code-validation.spec.ts rename to apps/v1/client/e2e/claude-code-validation.spec.ts index 8b4e2b49..602ba35c 100644 --- a/e2e/claude-code-validation.spec.ts +++ b/apps/v1/client/e2e/claude-code-validation.spec.ts @@ -3,17 +3,20 @@ import fs from 'fs/promises'; import path from 'path'; test.describe('Claude Code Chat Validation', () => { - test('should successfully navigate to Claude Code and display greeting message', async ({ page, testWorkspace }) => { + test('should successfully navigate to Claude Code and display greeting message', async ({ + page, + testWorkspace, + }) => { const testStartTime = Date.now(); const consoleLogs: string[] = []; const networkRequests: string[] = []; - + // Capture console logs page.on('console', (msg) => { const timestamp = Date.now() - testStartTime; consoleLogs.push(`[${timestamp}ms] [${msg.type()}] ${msg.text()}`); }); - + // Capture network requests page.on('request', (request) => { const timestamp = Date.now() - testStartTime; @@ -21,7 +24,7 @@ test.describe('Claude Code Chat Validation', () => { networkRequests.push(`[${timestamp}ms] ${request.method()} ${request.url()}`); } }); - + // Capture network responses page.on('response', (response) => { const timestamp = Date.now() - testStartTime; @@ -29,87 +32,106 @@ test.describe('Claude Code Chat Validation', () => { networkRequests.push(`[${timestamp}ms] RESPONSE ${response.status()} ${response.url()}`); } }); - + console.log('🚀 Starting Claude Code validation test...'); - + // Step 1: Set up workspace and navigate to projects page console.log('📍 Step 1: Setting up workspace'); await setupWorkspaceInBrowser(page, testWorkspace); console.log('📍 Step 2: Workspace setup complete, now on projects page'); - + // Step 3: Find and click test project console.log('📍 Step 3: Looking for test project'); const testProject = page.locator('[data-testid="project-card"]').first(); await expect(testProject).toBeVisible({ timeout: 10000 }); await testProject.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Step 4: Click Claude Code button console.log('📍 Step 4: Clicking Claude Code button'); const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { // Click somewhere else to close it await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + // Clear logs to focus on the critical part consoleLogs.length = 0; networkRequests.length = 0; consoleLogs.push(`[0ms] === CLAUDE CODE BUTTON CLICKED ===`); - + await claudeCodeButton.click(); - + // Step 5: Wait for Claude Code UI to appear console.log('📍 Step 5: Waiting for Claude Code UI'); await expect(page.locator('text=Claude Code Session')).toBeVisible({ timeout: 30000 }); - + // Step 6: Wait for greeting message or timeout console.log('📍 Step 6: Waiting for greeting message'); let greetingFound = false; let timeoutReached = false; - + // Wait up to 15 seconds for greeting message const greetingWaitStart = Date.now(); while (!greetingFound && !timeoutReached) { try { // Look for greeting message content - const messageElements = await page.locator('.claude-message, [role="message"], .message-content, .chat-message').all(); - + const messageElements = await page + .locator('.claude-message, [role="message"], .message-content, .chat-message') + .all(); + for (const element of messageElements) { const text = await element.textContent(); - if (text && (text.includes('Hello') || text.includes('Hi') || text.includes('Hey') || text.includes('Welcome') || text.includes('ready to') || text.includes('help you') || text.includes('Great to see you'))) { + if ( + text && + (text.includes('Hello') || + text.includes('Hi') || + text.includes('Hey') || + text.includes('Welcome') || + text.includes('ready to') || + text.includes('help you') || + text.includes('Great to see you')) + ) { greetingFound = true; console.log('✅ Greeting message found:', text.substring(0, 100)); break; } } - + // Also check for any text content that looks like a greeting const bodyText = await page.textContent('body'); - if (bodyText && (bodyText.includes('Hello') || bodyText.includes('Hi') || bodyText.includes('Hey') || bodyText.includes('Welcome') || bodyText.includes('help you') || bodyText.includes('Great to see you'))) { + if ( + bodyText && + (bodyText.includes('Hello') || + bodyText.includes('Hi') || + bodyText.includes('Hey') || + bodyText.includes('Welcome') || + bodyText.includes('help you') || + bodyText.includes('Great to see you')) + ) { greetingFound = true; console.log('✅ Greeting text found in page body'); } - + if (Date.now() - greetingWaitStart > 15000) { timeoutReached = true; } - + if (!greetingFound && !timeoutReached) { await page.waitForTimeout(500); } @@ -118,70 +140,80 @@ test.describe('Claude Code Chat Validation', () => { break; } } - + // Step 7: Analyze the results console.log('📍 Step 7: Analyzing results'); - + // Save console logs const logContent = consoleLogs.join('\n'); const networkContent = networkRequests.join('\n'); const combinedLogs = `=== CONSOLE LOGS ===\n${logContent}\n\n=== NETWORK REQUESTS ===\n${networkContent}`; - + await fs.writeFile('e2e-test-logs.txt', combinedLogs); console.log('📄 Logs saved to e2e-test-logs.txt'); - + // Take a screenshot for debugging await page.screenshot({ path: 'claude-code-state.png', fullPage: true }); console.log('📸 Screenshot saved to claude-code-state.png'); - + // Analyze console logs for mount/unmount patterns - const mountLogs = consoleLogs.filter(log => - log.includes('ClaudeCodeProvider') || - log.includes('SSE connection') || - log.includes('message-start') || - log.includes('message-chunk') + const mountLogs = consoleLogs.filter( + (log) => + log.includes('ClaudeCodeProvider') || + log.includes('SSE connection') || + log.includes('message-start') || + log.includes('message-chunk') ); - + console.log('\n🔍 Mount/Unmount Analysis:'); - mountLogs.forEach(log => console.log(log)); - + mountLogs.forEach((log) => console.log(log)); + // Check for the specific bug we fixed - const hasUnmountError = consoleLogs.some(log => + const hasUnmountError = consoleLogs.some((log) => log.includes('Component unmounted, skipping SSE setup') ); - + if (hasUnmountError) { console.log('\n❌ BUG DETECTED: Component unmounted before SSE setup!'); console.log('This is the exact issue that was causing chat messages not to appear.'); } - + // Check server logs try { const serverLogsPath = path.join(process.cwd(), 'server', 'logs'); - const claudeLog = await fs.readFile(path.join(serverLogsPath, 'claude-messages.log'), 'utf-8'); + const claudeLog = await fs.readFile( + path.join(serverLogsPath, 'claude-messages.log'), + 'utf-8' + ); const eventsLog = await fs.readFile(path.join(serverLogsPath, 'events.log'), 'utf-8'); - + const recentClaudeLines = claudeLog.split('\n').slice(-30); const recentEventLines = eventsLog.split('\n').slice(-30); - - await fs.writeFile('server-logs-analysis.txt', + + await fs.writeFile( + 'server-logs-analysis.txt', `=== RECENT CLAUDE MESSAGES ===\n${recentClaudeLines.join('\n')}\n\n=== RECENT EVENTS ===\n${recentEventLines.join('\n')}` ); - + console.log('📄 Server logs saved to server-logs-analysis.txt'); - + // Look for the problematic pattern - const hasNoActiveConnections = recentClaudeLines.some(line => line.includes('No active connections to send greeting to')); - const hasDisconnects = recentEventLines.some(line => line.includes('CLAUDE_SSE_DISCONNECTED')); - + const hasNoActiveConnections = recentClaudeLines.some((line) => + line.includes('No active connections to send greeting to') + ); + const hasDisconnects = recentEventLines.some((line) => + line.includes('CLAUDE_SSE_DISCONNECTED') + ); + if (hasNoActiveConnections && hasDisconnects) { - console.log('\n❌ ISSUE CONFIRMED: Server logs show SSE disconnections and no active connections!'); + console.log( + '\n❌ ISSUE CONFIRMED: Server logs show SSE disconnections and no active connections!' + ); } - } catch (error) { console.log('⚠️ Could not read server logs:', error); } - + // Report results if (greetingFound) { console.log('\n🎉 SUCCESS: Greeting message was displayed!'); @@ -189,15 +221,15 @@ test.describe('Claude Code Chat Validation', () => { } else { console.log('\n❌ FAILURE: Greeting message was NOT displayed'); console.log('💡 Check the logs for debugging information'); - + // Additional debugging const pageContent = await page.content(); await fs.writeFile('page-content-debug.html', pageContent); console.log('🔍 Full page content saved to page-content-debug.html'); } - + // The test assertions expect(hasUnmountError).toBe(false); // Should not have unmount error - expect(greetingFound).toBe(true); // Should have greeting message + expect(greetingFound).toBe(true); // Should have greeting message }); -}); \ No newline at end of file +}); diff --git a/e2e/feedback-feature.spec.ts b/apps/v1/client/e2e/feedback-feature.spec.ts similarity index 83% rename from e2e/feedback-feature.spec.ts rename to apps/v1/client/e2e/feedback-feature.spec.ts index 545c75fb..564f6fee 100644 --- a/e2e/feedback-feature.spec.ts +++ b/apps/v1/client/e2e/feedback-feature.spec.ts @@ -6,34 +6,37 @@ import fs from 'fs'; async function setupClaudeCodeSession(page: any) { // Navigate to dashboard await page.goto('http://localhost:5173'); - + // Create a new project await page.click('text=New Project'); await page.fill('input[placeholder="Enter project name"]', 'Test Feedback Project'); - await page.fill('textarea[placeholder="Enter project description"]', 'Project for testing feedback feature'); + await page.fill( + 'textarea[placeholder="Enter project description"]', + 'Project for testing feedback feature' + ); await page.click('button:has-text("Create Project")'); - + // Wait for project creation await page.waitForURL(/\/projects\/.+/); - + // Navigate to Claude Code await page.click('text=Claude Code'); await page.click('button:has-text("hello-world-1")'); - + // Wait for Claude Code to load await page.waitForSelector('[data-testid="message-list"]'); await page.waitForSelector('[data-testid="message-complete"]', { timeout: 30000 }); - + return page.url(); } test.describe('Feedback Feature', () => { test('should show feedback link on chat messages', async ({ page }) => { await setupClaudeCodeSession(page); - + // Find the first complete message const message = await page.locator('[data-testid="message-complete"]').first(); - + // Check for feedback link const feedbackLink = await message.locator('text=Leave feedback'); await expect(feedbackLink).toBeVisible(); @@ -41,10 +44,10 @@ test.describe('Feedback Feature', () => { test('should open feedback dialog when clicking feedback link', async ({ page }) => { await setupClaudeCodeSession(page); - + // Click feedback link on first message await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Check dialog appears await expect(page.locator('h2:has-text("Leave feedback")')).toBeVisible(); await expect(page.locator('text=Describe your feedback')).toBeVisible(); @@ -53,19 +56,19 @@ test.describe('Feedback Feature', () => { test('should validate required fields in feedback dialog', async ({ page }) => { await setupClaudeCodeSession(page); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Try to submit without filling the textarea await page.click('button:has-text("Submit feedback")'); - + // Should show validation error await expect(page.locator('text=Please provide your feedback')).toBeVisible(); - + // Fill the textarea await page.fill('textarea', 'Test feedback content'); - + // Should be able to submit now const submitButton = page.locator('button:has-text("Submit feedback")'); await expect(submitButton).toBeEnabled(); @@ -73,19 +76,19 @@ test.describe('Feedback Feature', () => { test('should capture screenshot when feedback is initiated', async ({ page }) => { await setupClaudeCodeSession(page); - + // Intercept screenshot upload - const screenshotPromise = page.waitForRequest(req => - req.url().includes('/api/feedback/screenshot') && req.method() === 'POST' + const screenshotPromise = page.waitForRequest( + (req) => req.url().includes('/api/feedback/screenshot') && req.method() === 'POST' ); - + // Open feedback dialog (triggers screenshot) await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Wait for screenshot request const screenshotReq = await screenshotPromise; const postData = screenshotReq.postDataJSON(); - + // Verify screenshot data expect(postData).toHaveProperty('imageData'); expect(postData.imageData).toMatch(/^data:image\/png;base64,/); @@ -95,25 +98,28 @@ test.describe('Feedback Feature', () => { test('should submit feedback with all required data', async ({ page }) => { await setupClaudeCodeSession(page); - + // Intercept feedback submission - const feedbackPromise = page.waitForRequest(req => - req.url().includes('/api/feedback/submit') && req.method() === 'POST' + const feedbackPromise = page.waitForRequest( + (req) => req.url().includes('/api/feedback/submit') && req.method() === 'POST' ); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Fill feedback form - await page.fill('textarea', 'What happened: Claude gave an unrelated response\nWhat I expected: Claude should understand the context'); - + await page.fill( + 'textarea', + 'What happened: Claude gave an unrelated response\nWhat I expected: Claude should understand the context' + ); + // Submit feedback await page.click('button:has-text("Submit feedback")'); - + // Wait for submission const feedbackReq = await feedbackPromise; const feedbackData = feedbackReq.postDataJSON(); - + // Verify feedback data structure - the component should parse the input expect(feedbackData).toHaveProperty('expectedBehavior'); expect(feedbackData).toHaveProperty('actualBehavior'); @@ -127,7 +133,7 @@ test.describe('Feedback Feature', () => { expect(feedbackData).toHaveProperty('mode'); expect(feedbackData).toHaveProperty('isConnected'); expect(feedbackData).toHaveProperty('screenshotPath'); - + // Messages should include at least the greeting expect(feedbackData.messages).toBeInstanceOf(Array); expect(feedbackData.messages.length).toBeGreaterThan(0); @@ -135,27 +141,27 @@ test.describe('Feedback Feature', () => { test('should show success dialog after feedback submission', async ({ page }) => { await setupClaudeCodeSession(page); - + // Mock successful API responses - await page.route('**/api/feedback/screenshot', async route => { + await page.route('**/api/feedback/screenshot', async (route) => { await route.fulfill({ status: 200, - json: { success: true, path: 'feedback/screenshots/test.png' } + json: { success: true, path: 'feedback/screenshots/test.png' }, }); }); - - await page.route('**/api/feedback/submit', async route => { + + await page.route('**/api/feedback/submit', async (route) => { await route.fulfill({ status: 200, - json: { success: true, feedbackId: 'fb-2024-01-15-abc123' } + json: { success: true, feedbackId: 'fb-2024-01-15-abc123' }, }); }); - + // Submit feedback await page.click('[data-testid="message-complete"] >> text=Leave feedback'); await page.fill('textarea', 'What happened: Test actual\nWhat I expected: Test expectation'); await page.click('button:has-text("Submit feedback")'); - + // Check success dialog await expect(page.locator('h2:has-text("Feedback Submitted")')).toBeVisible(); await expect(page.locator('text=Thank you for your feedback!')).toBeVisible(); @@ -164,25 +170,28 @@ test.describe('Feedback Feature', () => { test('should handle session-level feedback', async ({ page }) => { await setupClaudeCodeSession(page); - + // Intercept feedback submission - const feedbackPromise = page.waitForRequest(req => - req.url().includes('/api/feedback/submit') && req.method() === 'POST' + const feedbackPromise = page.waitForRequest( + (req) => req.url().includes('/api/feedback/submit') && req.method() === 'POST' ); - + // Click session feedback button await page.click('button:has-text("Leave feedback"):near(button:has-text("Close Session"))'); - + // Fill feedback form - await page.fill('textarea', 'What happened: Session had issues\nWhat I expected: Session should work properly'); - + await page.fill( + 'textarea', + 'What happened: Session had issues\nWhat I expected: Session should work properly' + ); + // Submit feedback await page.click('button:has-text("Submit feedback")'); - + // Wait for submission const feedbackReq = await feedbackPromise; const feedbackData = feedbackReq.postDataJSON(); - + // Verify no specific messageId for session feedback expect(feedbackData).toHaveProperty('messageId', undefined); expect(feedbackData).toHaveProperty('sessionId'); @@ -191,15 +200,17 @@ test.describe('Feedback Feature', () => { test('should show feedback link on tool executions', async ({ page }) => { await setupClaudeCodeSession(page); - + // Send a message to trigger tool execution await page.fill('[data-testid="claude-input"]', 'Please read the README file'); await page.keyboard.press('Enter'); - + // Wait for tool execution to appear await page.waitForSelector('[data-testid="tool-execution"]', { timeout: 30000 }); - await page.waitForSelector('[data-testid="tool-status"]:has-text("Complete")', { timeout: 30000 }); - + await page.waitForSelector('[data-testid="tool-status"]:has-text("Complete")', { + timeout: 30000, + }); + // Check for feedback link on tool execution const toolExecution = await page.locator('[data-testid="tool-execution"]').first(); const feedbackLink = await toolExecution.locator('text=Leave feedback'); @@ -208,65 +219,70 @@ test.describe('Feedback Feature', () => { test('should handle screenshot capture failure gracefully', async ({ page }) => { await setupClaudeCodeSession(page); - + // Mock screenshot failure await page.addInitScript(() => { // Override dom-to-image to fail (window as any).domtoimage = { - toPng: () => Promise.reject(new Error('Screenshot capture failed')) + toPng: () => Promise.reject(new Error('Screenshot capture failed')), }; }); - + // Mock successful submit (even without screenshot) - await page.route('**/api/feedback/submit', async route => { + await page.route('**/api/feedback/submit', async (route) => { await route.fulfill({ status: 200, - json: { success: true, feedbackId: 'fb-2024-01-15-no-screenshot' } + json: { success: true, feedbackId: 'fb-2024-01-15-no-screenshot' }, }); }); - + // Submit feedback await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - await page.fill('textarea', 'What happened: Screenshot failed but feedback works\nWhat I expected: Test without screenshot'); + await page.fill( + 'textarea', + 'What happened: Screenshot failed but feedback works\nWhat I expected: Test without screenshot' + ); await page.click('button:has-text("Submit feedback")'); - + // Should still succeed await expect(page.locator('h2:has-text("Feedback Submitted")')).toBeVisible(); }); test('should include correct message context in feedback', async ({ page }) => { await setupClaudeCodeSession(page); - + // Send a user message await page.fill('[data-testid="claude-input"]', 'Test message for feedback'); await page.keyboard.press('Enter'); - + // Wait for response - await page.waitForSelector('[data-testid="message-complete"]:nth-of-type(3)', { timeout: 30000 }); - + await page.waitForSelector('[data-testid="message-complete"]:nth-of-type(3)', { + timeout: 30000, + }); + // Intercept feedback submission - const feedbackPromise = page.waitForRequest(req => - req.url().includes('/api/feedback/submit') && req.method() === 'POST' + const feedbackPromise = page.waitForRequest( + (req) => req.url().includes('/api/feedback/submit') && req.method() === 'POST' ); - + // Click feedback on the user's message const userMessage = await page.locator('[data-testid="message-complete"]').nth(1); await userMessage.locator('text=Leave feedback').click(); - + // Submit feedback await page.fill('textarea', 'Test feedback for message context'); await page.click('button:has-text("Submit feedback")'); - + // Check feedback data const feedbackReq = await feedbackPromise; const feedbackData = feedbackReq.postDataJSON(); - + // Should have at least 3 messages (greeting, user message, response) expect(feedbackData.messages.length).toBeGreaterThanOrEqual(3); - + // Find the user message in the feedback - const userMsgInFeedback = feedbackData.messages.find((m: any) => - m.content === 'Test message for feedback' + const userMsgInFeedback = feedbackData.messages.find( + (m: any) => m.content === 'Test message for feedback' ); expect(userMsgInFeedback).toBeDefined(); expect(feedbackData.messageId).toBe(userMsgInFeedback.id); @@ -278,17 +294,18 @@ test.describe('Feedback Server Storage', () => { // Submit screenshot directly to API const response = await request.post('http://localhost:3000/api/feedback/screenshot', { data: { - imageData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + imageData: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', sessionId: 'test-session-123', - repoName: 'test-repo' - } + repoName: 'test-repo', + }, }); - + expect(response.ok()).toBeTruthy(); const result = await response.json(); expect(result.success).toBe(true); expect(result.path).toMatch(/^feedback\/screenshots\/test-repo-test-session-123-\d+\.png$/); - + // Verify file exists const screenshotPath = path.join(__dirname, '..', result.path); expect(fs.existsSync(screenshotPath)).toBe(true); @@ -305,33 +322,29 @@ test.describe('Feedback Server Storage', () => { projectId: 'test-project', messageId: 'msg-123', timestamp: new Date().toISOString(), - messages: [ - { id: 'msg-123', role: 'user', content: 'Test message' } - ], + messages: [{ id: 'msg-123', role: 'user', content: 'Test message' }], mode: 'default', isConnected: true, - screenshotPath: 'feedback/screenshots/test.png' - } + screenshotPath: 'feedback/screenshots/test.png', + }, }); - + expect(response.ok()).toBeTruthy(); const result = await response.json(); expect(result.success).toBe(true); expect(result.feedbackId).toMatch(/^fb-\d{4}-\d{2}-\d{2}-[a-z0-9]{6}$/); - + // Verify feedback file exists const reportsDir = path.join(__dirname, '..', 'feedback', 'reports'); const files = fs.readdirSync(reportsDir); - const feedbackFile = files.find(f => f.includes('test-session-456')); + const feedbackFile = files.find((f) => f.includes('test-session-456')); expect(feedbackFile).toBeDefined(); - + // Read and verify content - const content = JSON.parse( - fs.readFileSync(path.join(reportsDir, feedbackFile!), 'utf8') - ); + const content = JSON.parse(fs.readFileSync(path.join(reportsDir, feedbackFile!), 'utf8')); expect(content.feedbackId).toBe(result.feedbackId); expect(content.user.expectedBehavior).toBe('Test expected'); expect(content.user.actualBehavior).toBe('Test actual'); expect(content.serverLogs).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/e2e/feedback-fixes.spec.ts b/apps/v1/client/e2e/feedback-fixes.spec.ts similarity index 87% rename from e2e/feedback-fixes.spec.ts rename to apps/v1/client/e2e/feedback-fixes.spec.ts index 81fed81b..c60de04e 100644 --- a/e2e/feedback-fixes.spec.ts +++ b/apps/v1/client/e2e/feedback-fixes.spec.ts @@ -4,38 +4,45 @@ import { test, expect } from '@playwright/test'; async function setupClaudeCodeSession(page: any) { // Navigate to dashboard await page.goto('http://localhost:5173'); - + // Create a new project await page.click('text=New Project'); await page.fill('input[placeholder="Enter project name"]', 'Test Feedback Fixes'); - await page.fill('textarea[placeholder="Enter project description"]', 'Testing feedback dialog fixes'); + await page.fill( + 'textarea[placeholder="Enter project description"]', + 'Testing feedback dialog fixes' + ); await page.click('button:has-text("Create Project")'); - + // Wait for project creation await page.waitForURL(/\/projects\/.+/); - + // Navigate to Claude Code await page.click('text=Claude Code'); await page.click('button:has-text("hello-world-1")'); - + // Wait for Claude Code to load await page.waitForSelector('[data-testid="message-list"]'); await page.waitForSelector('[data-testid="message-complete"]', { timeout: 30000 }); - + return page.url(); } test.describe('Feedback Dialog Fixes', () => { - test('feedback dialog should be centered on window and use portal rendering', async ({ page }) => { + test('feedback dialog should be centered on window and use portal rendering', async ({ + page, + }) => { await setupClaudeCodeSession(page); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Check that dialog is rendered at document.body level (portal) - const dialog = await page.locator('body > div').filter({ has: page.locator('h2:has-text("Leave feedback")') }); + const dialog = await page + .locator('body > div') + .filter({ has: page.locator('h2:has-text("Leave feedback")') }); await expect(dialog).toBeVisible(); - + // Check centering styles const dialogContainer = await dialog.locator('div.fixed.inset-0.z-\\[9999\\]'); await expect(dialogContainer).toHaveCSS('display', 'flex'); @@ -45,46 +52,46 @@ test.describe('Feedback Dialog Fixes', () => { test('feedback dialog should have a single combined textarea', async ({ page }) => { await setupClaudeCodeSession(page); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Should have only one textarea const textareas = await page.locator('textarea'); await expect(textareas).toHaveCount(1); - + // Check placeholder text const textarea = await page.locator('textarea'); const placeholder = await textarea.getAttribute('placeholder'); expect(placeholder).toContain('Describe what happened and what you expected'); - + // Check label await expect(page.locator('label:has-text("Describe your feedback")')).toBeVisible(); }); test('feedback dialog should be draggable by header', async ({ page }) => { await setupClaudeCodeSession(page); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Get initial position const dialog = await page.locator('div.relative.p-4.w-full.max-w-2xl'); const initialBox = await dialog.boundingBox(); expect(initialBox).toBeTruthy(); - + // Find the header (draggable area) const header = await page.locator('h2:has-text("Leave feedback")').locator('..'); - + // Check header has grab cursor await expect(header).toHaveCSS('cursor', 'grab'); - + // Drag the dialog await header.hover(); await page.mouse.down(); await page.mouse.move(initialBox!.x + 100, initialBox!.y + 50); await page.mouse.up(); - + // Check dialog moved const newBox = await dialog.boundingBox(); expect(newBox).toBeTruthy(); @@ -94,26 +101,30 @@ test.describe('Feedback Dialog Fixes', () => { test('suggested responses should appear for plan mode questions', async ({ page }) => { await setupClaudeCodeSession(page); - + // Switch to plan mode await page.click('button[aria-label="Claude mode"]:has-text("Default")'); await page.click('text=Plan'); - + // Send a message that should trigger a plan response await page.fill('[data-testid="claude-input"]', 'Help me implement a new feature'); await page.keyboard.press('Enter'); - + // Wait for assistant response - await page.waitForSelector('[data-testid="message-complete"]:nth-of-type(3)', { timeout: 30000 }); - + await page.waitForSelector('[data-testid="message-complete"]:nth-of-type(3)', { + timeout: 30000, + }); + // Mock an assistant response with plan approval question await page.evaluate(() => { // Find the last assistant message and add suggested responses const messages = document.querySelectorAll('[data-testid="message-complete"]'); - const lastMessage = Array.from(messages).reverse().find(m => - m.querySelector('.text-green-500') // Assistant messages have green avatar - ); - + const lastMessage = Array.from(messages) + .reverse() + .find( + (m) => m.querySelector('.text-green-500') // Assistant messages have green avatar + ); + if (lastMessage) { // Inject a test message that should trigger suggested responses const content = lastMessage.querySelector('.prose'); @@ -122,7 +133,7 @@ test.describe('Feedback Dialog Fixes', () => { } } }); - + // Check for suggested response buttons await expect(page.locator('button:has-text("Yes, proceed with the plan")')).toBeVisible(); await expect(page.locator('button:has-text("No, let me review more")')).toBeVisible(); @@ -131,7 +142,7 @@ test.describe('Feedback Dialog Fixes', () => { test('numbered lists should render with proper formatting', async ({ page }) => { await setupClaudeCodeSession(page); - + // Mock a message with a numbered list await page.evaluate(() => { const messageList = document.querySelector('[data-testid="message-list"]'); @@ -152,16 +163,16 @@ test.describe('Feedback Dialog Fixes', () => { messageList.appendChild(testMessage); } }); - + // Check list formatting const list = await page.locator('ol.list-decimal'); await expect(list).toBeVisible(); await expect(list).toHaveCSS('margin-left', '24px'); // ml-6 = 1.5rem = 24px - + // Check list items const items = await list.locator('li'); await expect(items).toHaveCount(3); - + // Verify numbers are not on separate lines const firstItem = await items.first(); const firstItemText = await firstItem.textContent(); @@ -170,67 +181,75 @@ test.describe('Feedback Dialog Fixes', () => { test('mode should switch from plan to execution when user approves', async ({ page }) => { await setupClaudeCodeSession(page); - + // Switch to plan mode await page.click('button[aria-label="Claude mode"]:has-text("Default")'); await page.click('text=Plan'); - + // Verify we're in plan mode await expect(page.locator('button[aria-label="Claude mode"]:has-text("Plan")')).toBeVisible(); - + // Send approval message await page.fill('[data-testid="claude-input"]', 'Yes, proceed with the implementation'); await page.keyboard.press('Enter'); - + // Mode should switch back to default - await expect(page.locator('button[aria-label="Claude mode"]:has-text("Default")')).toBeVisible(); + await expect( + page.locator('button[aria-label="Claude mode"]:has-text("Default")') + ).toBeVisible(); }); test('feedback dialog should validate single textarea input', async ({ page }) => { await setupClaudeCodeSession(page); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Try to submit without filling the textarea await page.click('button:has-text("Submit feedback")'); - + // Should show validation error await expect(page.locator('text=Please provide your feedback')).toBeVisible(); - + // Fill the textarea - await page.fill('textarea', 'What happened: The dialog was not centered\nWhat I expected: Dialog to be centered on the window'); - + await page.fill( + 'textarea', + 'What happened: The dialog was not centered\nWhat I expected: Dialog to be centered on the window' + ); + // Submit should now work await page.click('button:has-text("Submit feedback")'); - + // Dialog should close (or show success) await expect(page.locator('h2:has-text("Leave feedback")')).not.toBeVisible({ timeout: 5000 }); }); test('feedback should parse combined textarea correctly', async ({ page }) => { await setupClaudeCodeSession(page); - + // Intercept feedback submission - const feedbackPromise = page.waitForRequest(req => - req.url().includes('/api/feedback/submit') && req.method() === 'POST' + const feedbackPromise = page.waitForRequest( + (req) => req.url().includes('/api/feedback/submit') && req.method() === 'POST' ); - + // Open feedback dialog await page.click('[data-testid="message-complete"] >> text=Leave feedback'); - + // Fill with structured feedback - await page.fill('textarea', 'What happened: The button did not appear\nWhat I expected: Button should be visible'); - + await page.fill( + 'textarea', + 'What happened: The button did not appear\nWhat I expected: Button should be visible' + ); + // Submit feedback await page.click('button:has-text("Submit feedback")'); - + // Check the parsed data const feedbackReq = await feedbackPromise; const feedbackData = feedbackReq.postDataJSON(); - + // Should parse the sections correctly expect(feedbackData.actualBehavior).toContain('The button did not appear'); expect(feedbackData.expectedBehavior).toContain('Button should be visible'); }); -}); \ No newline at end of file +}); diff --git a/e2e/session-rehydration.spec.ts b/apps/v1/client/e2e/session-rehydration.spec.ts similarity index 72% rename from e2e/session-rehydration.spec.ts rename to apps/v1/client/e2e/session-rehydration.spec.ts index 71c03128..4438ccfd 100644 --- a/e2e/session-rehydration.spec.ts +++ b/apps/v1/client/e2e/session-rehydration.spec.ts @@ -1,120 +1,136 @@ import { test, expect, setupWorkspaceInBrowser } from './test-setup'; test.describe('Session Rehydration', () => { - test('should preserve chat messages when navigating away and back to a session', async ({ page, testWorkspace }) => { + test('should preserve chat messages when navigating away and back to a session', async ({ + page, + testWorkspace, + }) => { // Set up the test workspace in the browser await setupWorkspaceInBrowser(page, testWorkspace); - + // Find and click on the test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); - + // Wait for project detail page to load await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { // Click somewhere else to close it await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code interface to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - + // Wait for the greeting message to appear and verify it has content const messageList = page.locator('[data-testid="message-list"]'); await expect(messageList).toBeVisible(); - + // Wait for greeting message to be received and displayed - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length > 0 && messages[0].textContent && messages[0].textContent.trim().length > 0; - }, { timeout: 10000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return ( + messages.length > 0 && + messages[0].textContent && + messages[0].textContent.trim().length > 0 + ); + }, + { timeout: 10000 } + ); + // Get the greeting message content const firstMessage = page.locator('[data-testid="message-bubble"]').first(); await expect(firstMessage).toBeVisible(); const originalContent = await firstMessage.textContent(); - + // Verify the greeting message has meaningful content expect(originalContent).toBeTruthy(); expect(originalContent!.length).toBeGreaterThan(10); // Should have substantial content - + console.log('Original message content:', originalContent); - + // Send a test message to create more chat history const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill('Hello Claude!'); await messageInput.press('Enter'); - + // Wait for the user message to appear (we'll just check for 2 messages - greeting + user) // Don't wait for response as Claude might be slow in test environment - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - if (messages.length >= 2) { - // Check that second message contains our test text - const userMessage = messages[1]; - return userMessage.textContent && userMessage.textContent.includes('Hello Claude'); - } - return false; - }, { timeout: 10000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + if (messages.length >= 2) { + // Check that second message contains our test text + const userMessage = messages[1]; + return userMessage.textContent && userMessage.textContent.includes('Hello Claude'); + } + return false; + }, + { timeout: 10000 } + ); + // Navigate away from the session (go back to project detail) await page.goBack(); await page.waitForURL('**/projects/**'); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Navigate back to the same session using Claude Code button const claudeCodeButtonAgain = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButtonAgain).toBeVisible(); await claudeCodeButtonAgain.click(); - + // Wait for Claude Code interface to load again await page.waitForTimeout(1000); // Give time for navigation - + // Wait for messages to be restored - at least the greeting and user message - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 2; // Should have at least greeting + user message - }, { timeout: 10000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 2; // Should have at least greeting + user message + }, + { timeout: 10000 } + ); + // Verify that all messages are still present with correct content const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); expect(messageCount).toBeGreaterThanOrEqual(2); // At least greeting + user message - + // Verify the greeting message content is preserved const restoredFirstMessage = messages.first(); const restoredContent = await restoredFirstMessage.textContent(); - + console.log('Restored message content:', restoredContent); - + // The restored content should match the original content expect(restoredContent).toBeTruthy(); expect(restoredContent!.length).toBeGreaterThan(10); // Should have substantial content expect(restoredContent).not.toBe(''); // Should not be empty - + // Verify that we have at least 2 messages and they have content if (messageCount >= 2) { const secondMessage = messages.nth(1); @@ -123,83 +139,116 @@ test.describe('Session Rehydration', () => { expect(secondContent!.length).toBeGreaterThan(5); } }); - + test('should handle multiple session switches correctly', async ({ page, testWorkspace }) => { // Set up the test workspace in the browser await setupWorkspaceInBrowser(page, testWorkspace); - + const projectCard = page.locator('[data-testid="project-card"]').first(); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Start session with first repo const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); const repo1Text = await claudeCodeButton.getAttribute('data-repo-name'); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - + // Wait for greeting and verify content - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length > 0 && messages[0].textContent && messages[0].textContent.trim().length > 0; - }, { timeout: 10000 }); - - const session1Content = await page.locator('[data-testid="message-bubble"]').first().textContent(); + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return ( + messages.length > 0 && + messages[0].textContent && + messages[0].textContent.trim().length > 0 + ); + }, + { timeout: 10000 } + ); + + const session1Content = await page + .locator('[data-testid="message-bubble"]') + .first() + .textContent(); expect(session1Content).toBeTruthy(); expect(session1Content!.length).toBeGreaterThan(10); - + // If there's a second repo, test switching between them await page.goBack(); await page.waitForURL('**/projects/**'); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - - const repo2Button = page.locator('button').filter({ hasText: /hello-world-\d+/i }).nth(1); - if (await repo2Button.count() > 0) { + + const repo2Button = page + .locator('button') + .filter({ hasText: /hello-world-\d+/i }) + .nth(1); + if ((await repo2Button.count()) > 0) { const repo2Text = await repo2Button.textContent(); await repo2Button.click(); await page.waitForTimeout(1000); // Give time for navigation - + // Wait for different greeting - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length > 0 && messages[0].textContent && messages[0].textContent.trim().length > 0; - }, { timeout: 10000 }); - - const session2Content = await page.locator('[data-testid="message-bubble"]').first().textContent(); + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return ( + messages.length > 0 && + messages[0].textContent && + messages[0].textContent.trim().length > 0 + ); + }, + { timeout: 10000 } + ); + + const session2Content = await page + .locator('[data-testid="message-bubble"]') + .first() + .textContent(); expect(session2Content).toContain('Hello-World'); - + // Go back to first repo and verify original content is preserved await page.goBack(); await page.waitForTimeout(1000); // Give time for navigation - + await repo1Button.click(); await page.waitForTimeout(1000); // Give time for navigation - - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length > 0 && messages[0].textContent && messages[0].textContent.trim().length > 0; - }, { timeout: 10000 }); - - const restoredSession1Content = await page.locator('[data-testid="message-bubble"]').first().textContent(); + + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return ( + messages.length > 0 && + messages[0].textContent && + messages[0].textContent.trim().length > 0 + ); + }, + { timeout: 10000 } + ); + + const restoredSession1Content = await page + .locator('[data-testid="message-bubble"]') + .first() + .textContent(); expect(restoredSession1Content).toBeTruthy(); expect(restoredSession1Content!.length).toBeGreaterThan(10); } else { @@ -209,10 +258,13 @@ test.describe('Session Rehydration', () => { await claudeCodeButtonAgain.click(); await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - - const restoredContent = await page.locator('[data-testid="message-bubble"]').first().textContent(); + + const restoredContent = await page + .locator('[data-testid="message-bubble"]') + .first() + .textContent(); expect(restoredContent).toBeTruthy(); expect(restoredContent!.length).toBeGreaterThan(10); } }); -}); \ No newline at end of file +}); diff --git a/e2e/simple-breadcrumb.spec.ts b/apps/v1/client/e2e/simple-breadcrumb.spec.ts similarity index 97% rename from e2e/simple-breadcrumb.spec.ts rename to apps/v1/client/e2e/simple-breadcrumb.spec.ts index 73c74419..5cc96537 100644 --- a/e2e/simple-breadcrumb.spec.ts +++ b/apps/v1/client/e2e/simple-breadcrumb.spec.ts @@ -3,15 +3,15 @@ import { test, expect, setupWorkspaceInBrowser } from './test-setup'; test.describe('Simple Breadcrumb Test', () => { test('should show projects page and basic navigation', async ({ page, testWorkspace }) => { await setupWorkspaceInBrowser(page, testWorkspace); - + // Just verify we can see the projects page - Projects is in the sidebar nav await expect(page.locator('text=Projects').first()).toBeVisible({ timeout: 10000 }); - + // Look for any project cards const projectCards = page.locator('[data-testid="project-card"]'); const count = await projectCards.count(); console.log(`Found ${count} project cards`); - + if (count > 0) { const firstProject = projectCards.first(); await expect(firstProject).toBeVisible(); @@ -19,4 +19,4 @@ test.describe('Simple Breadcrumb Test', () => { console.log(`First project: ${projectText}`); } }); -}); \ No newline at end of file +}); diff --git a/e2e/test-dancing-bubbles.spec.ts b/apps/v1/client/e2e/test-dancing-bubbles.spec.ts similarity index 95% rename from e2e/test-dancing-bubbles.spec.ts rename to apps/v1/client/e2e/test-dancing-bubbles.spec.ts index 0777643c..f495aa10 100644 --- a/e2e/test-dancing-bubbles.spec.ts +++ b/apps/v1/client/e2e/test-dancing-bubbles.spec.ts @@ -13,98 +13,101 @@ test.describe('Claude Code Dancing Bubbles', () => { } }); - test('should show dancing bubbles while waiting for response', async ({ page, testWorkspace }) => { + test('should show dancing bubbles while waiting for response', async ({ + page, + testWorkspace, + }) => { // Set up workspace and navigate to Claude Code await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any blocking UI elements const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a message const testMessage = 'Please count to 5 slowly'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait a moment for the placeholder to be added await page.waitForTimeout(500); - + // Check for dancing bubbles in the latest message const messages = page.locator('[data-testid="message-bubble"]'); const messageCountAfterSend = await messages.count(); console.log('Message count after send:', messageCountAfterSend); - + // Should have at least: greeting + user message + placeholder expect(messageCountAfterSend).toBeGreaterThanOrEqual(3); - + // Check for dancing bubbles indicator const dancingBubbles = page.locator('[data-testid="dancing-bubbles"]'); const dancingBubblesCount = await dancingBubbles.count(); console.log('Dancing bubbles found:', dancingBubblesCount); - + if (dancingBubblesCount === 0) { // If no dancing bubbles found by test-id, look for the animation class const animatedElements = page.locator('.animate-pulse, .animate-bounce'); const animatedCount = await animatedElements.count(); console.log('Animated elements found:', animatedCount); - + // Look in the last assistant message const lastAssistantMessage = messages.filter({ hasText: 'C' }).last(); const bubbleContent = await lastAssistantMessage.textContent(); console.log('Last assistant message content:', bubbleContent); } - + // Wait for actual content to start streaming await page.waitForTimeout(3000); - + // Dancing bubbles should be gone and replaced with actual content const finalDancingBubblesCount = await dancingBubbles.count(); console.log('Final dancing bubbles count:', finalDancingBubblesCount); - + // Check that we now have actual content const finalMessages = page.locator('[data-testid="message-bubble"]'); const finalMessageCount = await finalMessages.count(); const lastMessage = finalMessages.nth(finalMessageCount - 1); const lastMessageText = await lastMessage.textContent(); - + console.log('Final message text:', lastMessageText); - + // Should have actual content now, not just empty expect(lastMessageText).not.toBe(''); expect(lastMessageText).toContain('C'); // Avatar - + // Take screenshot for debugging await page.screenshot({ path: 'dancing-bubbles-test.png', fullPage: true }); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-feedback-autofocus.spec.ts b/apps/v1/client/e2e/test-feedback-autofocus.spec.ts similarity index 96% rename from e2e/test-feedback-autofocus.spec.ts rename to apps/v1/client/e2e/test-feedback-autofocus.spec.ts index 0ffa6afe..0447f192 100644 --- a/e2e/test-feedback-autofocus.spec.ts +++ b/apps/v1/client/e2e/test-feedback-autofocus.spec.ts @@ -13,52 +13,55 @@ test.describe('Feedback Dialog Autofocus', () => { } }); - test('should automatically focus the feedback textarea when dialog opens', async ({ page, testWorkspace }) => { + test('should automatically focus the feedback textarea when dialog opens', async ({ + page, + testWorkspace, + }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Find and click a feedback link const feedbackLink = page.locator('button:has-text("Leave feedback")').first(); await expect(feedbackLink).toBeVisible({ timeout: 10000 }); await feedbackLink.click(); - + // Wait for feedback dialog to appear await page.waitForSelector('h2:has-text("Leave feedback")', { timeout: 5000 }); - + // Wait a bit for the autofocus to trigger await page.waitForTimeout(200); - + // Check if the textarea is focused const focusedElement = await page.evaluate(() => document.activeElement?.tagName); expect(focusedElement).toBe('TEXTAREA'); - + // Also verify we can immediately start typing await page.keyboard.type('This is a test of autofocus'); - + // Check that the text was entered const textarea = page.locator('textarea').first(); const value = await textarea.inputValue(); expect(value).toBe('This is a test of autofocus'); - + // Take a screenshot for debugging await page.screenshot({ path: 'feedback-autofocus.png', fullPage: true }); }); @@ -66,48 +69,48 @@ test.describe('Feedback Dialog Autofocus', () => { test('should maintain focus when dragging the dialog', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message and click feedback await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); const feedbackLink = page.locator('button:has-text("Leave feedback")').first(); await feedbackLink.click(); - + // Wait for dialog and autofocus await page.waitForSelector('h2:has-text("Leave feedback")', { timeout: 5000 }); await page.waitForTimeout(200); - + // Type some text await page.keyboard.type('Test text'); - + // Drag the dialog header const dialogHeader = page.locator('h2:has-text("Leave feedback")').locator('..'); await dialogHeader.hover(); await page.mouse.down(); await page.mouse.move(100, 100); await page.mouse.up(); - + // Continue typing to verify focus is maintained await page.keyboard.type(' after drag'); - + // Verify the full text const textarea = page.locator('textarea').first(); const value = await textarea.inputValue(); expect(value).toBe('Test text after drag'); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-ls-tool-display.spec.ts b/apps/v1/client/e2e/test-ls-tool-display.spec.ts similarity index 95% rename from e2e/test-ls-tool-display.spec.ts rename to apps/v1/client/e2e/test-ls-tool-display.spec.ts index 327115d4..7989c7be 100644 --- a/e2e/test-ls-tool-display.spec.ts +++ b/apps/v1/client/e2e/test-ls-tool-display.spec.ts @@ -16,109 +16,112 @@ test.describe('LS Tool Display', () => { test('should display folder name for LS tool, not JSON', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a message that will trigger LS tool const testMessage = 'List the files in the src directory'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for tool execution to appear await page.waitForSelector('[data-testid="tool-execution"]', { timeout: 10000 }); - + // Get the tool execution element const toolExecution = page.locator('[data-testid="tool-execution"]').first(); - + // Check that the tool name is correct const toolName = toolExecution.locator('[data-testid="tool-name"]'); await expect(toolName).toContainText('List directory'); - + // Check that the folder name is displayed (not "json") const toolArgs = toolExecution.locator('.font-mono').first(); const argsText = await toolArgs.textContent(); - + // Verify it shows "src" and not "json" expect(argsText?.toLowerCase()).toContain('src'); expect(argsText?.toLowerCase()).not.toBe('json'); - + // Take a screenshot for debugging await page.screenshot({ path: 'ls-tool-display.png', fullPage: true }); }); - test('should display "current directory" for LS tool when path is "."', async ({ page, testWorkspace }) => { + test('should display "current directory" for LS tool when path is "."', async ({ + page, + testWorkspace, + }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a message that will trigger LS tool on current directory const testMessage = 'List files in the current directory'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); await messageInput.press('Enter'); - + // Wait for tool execution await page.waitForSelector('[data-testid="tool-execution"]', { timeout: 10000 }); - + // Get the tool execution element const toolExecution = page.locator('[data-testid="tool-execution"]').first(); - + // Check that it shows "current directory" const toolArgs = toolExecution.locator('.font-mono').first(); const argsText = await toolArgs.textContent(); - + expect(argsText).toBe('current directory'); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-markdown-elements.spec.ts b/apps/v1/client/e2e/test-markdown-elements.spec.ts similarity index 91% rename from e2e/test-markdown-elements.spec.ts rename to apps/v1/client/e2e/test-markdown-elements.spec.ts index 277c8bdf..6d1cfa13 100644 --- a/e2e/test-markdown-elements.spec.ts +++ b/apps/v1/client/e2e/test-markdown-elements.spec.ts @@ -15,35 +15,35 @@ test.describe('Claude Code Markdown Elements', () => { const navigateToClaudeCode = async (page: any, testWorkspace: string) => { await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); }; @@ -53,19 +53,22 @@ test.describe('Claude Code Markdown Elements', () => { await expect(messageInput).toBeVisible(); await messageInput.fill(message); await messageInput.press('Enter'); - + // Wait for response to start await page.waitForTimeout(2000); - + // Wait for new message - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 3; // greeting + user + response - }, { timeout: 30000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 3; // greeting + user + response + }, + { timeout: 30000 } + ); + // Wait for streaming to complete await page.waitForTimeout(3000); - + // Get the last message const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); @@ -74,21 +77,21 @@ test.describe('Claude Code Markdown Elements', () => { test('should render headers without hash symbols', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - + const message = 'Please respond with exactly: "# Header 1\\n## Header 2\\n### Header 3"'; const response = await sendMessageAndWaitForResponse(page, message); - + const responseText = await response.textContent(); console.log('Headers test - Response text:', responseText); - + // Should not show raw markdown expect(responseText).not.toContain('# Header'); - + // Should have actual header elements const h1Count = await response.locator('h1').count(); const h2Count = await response.locator('h2').count(); const h3Count = await response.locator('h3').count(); - + expect(h1Count).toBeGreaterThan(0); expect(h2Count).toBeGreaterThan(0); expect(h3Count).toBeGreaterThan(0); @@ -96,24 +99,25 @@ test.describe('Claude Code Markdown Elements', () => { test('should render lists and checkboxes properly', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - - const message = 'Please respond with exactly: "- Item 1\\n- Item 2\\n\\n- [ ] Unchecked\\n- [x] Checked"'; + + const message = + 'Please respond with exactly: "- Item 1\\n- Item 2\\n\\n- [ ] Unchecked\\n- [x] Checked"'; const response = await sendMessageAndWaitForResponse(page, message); - + const responseText = await response.textContent(); console.log('Lists test - Response text:', responseText); - + // Should have list elements const ulCount = await response.locator('ul').count(); const liCount = await response.locator('li').count(); - + expect(ulCount).toBeGreaterThan(0); expect(liCount).toBeGreaterThanOrEqual(4); - + // Should have checkboxes const checkboxCount = await response.locator('input[type="checkbox"]').count(); expect(checkboxCount).toBe(2); - + // One should be checked const checkedCount = await response.locator('input[type="checkbox"][checked]').count(); expect(checkedCount).toBe(1); @@ -121,20 +125,20 @@ test.describe('Claude Code Markdown Elements', () => { test('should render inline code without backticks', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - + const message = 'Please respond with exactly: "Here is `inline code` example"'; const response = await sendMessageAndWaitForResponse(page, message); - + const responseText = await response.textContent(); console.log('Inline code test - Response text:', responseText); - + // Should not contain backticks expect(responseText).not.toContain('`'); - + // Should have code element const codeCount = await response.locator('code').count(); expect(codeCount).toBeGreaterThan(0); - + // Code element should contain the text without backticks const codeElement = response.locator('code').first(); const codeText = await codeElement.textContent(); @@ -143,56 +147,57 @@ test.describe('Claude Code Markdown Elements', () => { test('should render code blocks without triple backticks', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - - const message = 'Please respond with exactly: "```\\nfunction test() {\\n return true;\\n}\\n```"'; + + const message = + 'Please respond with exactly: "```\\nfunction test() {\\n return true;\\n}\\n```"'; const response = await sendMessageAndWaitForResponse(page, message); - + const responseText = await response.textContent(); console.log('Code block test - Response text:', responseText); - + // Should not contain triple backticks expect(responseText).not.toContain('```'); - + // Should have pre element const preCount = await response.locator('pre').count(); expect(preCount).toBeGreaterThan(0); - + // Should contain the function expect(responseText).toContain('function test()'); }); test('should not display [object Object]', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - + const message = 'Say hello with **bold** and *italic* text'; const response = await sendMessageAndWaitForResponse(page, message); - + const responseText = await response.textContent(); console.log('Object test - Response text:', responseText); - + // Should never show [object Object] expect(responseText).not.toContain('[object Object]'); - + // Should have formatted text const strongCount = await response.locator('strong').count(); const emCount = await response.locator('em').count(); - + expect(strongCount).toBeGreaterThan(0); expect(emCount).toBeGreaterThan(0); }); test('should apply CSS classes correctly', async ({ page, testWorkspace }) => { await navigateToClaudeCode(page, testWorkspace); - + const message = 'Please respond with exactly: "# Big Header\\n\\n> A quote"'; const response = await sendMessageAndWaitForResponse(page, message); - + // Check header has correct classes const h1 = response.locator('h1').first(); const h1Classes = await h1.getAttribute('class'); expect(h1Classes).toContain('text-2xl'); expect(h1Classes).toContain('font-bold'); - + // Check blockquote has correct classes const blockquote = response.locator('blockquote').first(); const blockquoteClasses = await blockquote.getAttribute('class'); @@ -200,4 +205,4 @@ test.describe('Claude Code Markdown Elements', () => { expect(blockquoteClasses).toContain('pl-4'); expect(blockquoteClasses).toContain('italic'); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-markdown-rendering-comprehensive.spec.ts b/apps/v1/client/e2e/test-markdown-rendering-comprehensive.spec.ts similarity index 92% rename from e2e/test-markdown-rendering-comprehensive.spec.ts rename to apps/v1/client/e2e/test-markdown-rendering-comprehensive.spec.ts index 7b1bd404..dcaad8a1 100644 --- a/e2e/test-markdown-rendering-comprehensive.spec.ts +++ b/apps/v1/client/e2e/test-markdown-rendering-comprehensive.spec.ts @@ -13,27 +13,30 @@ test.describe('Claude Code Comprehensive Markdown Rendering', () => { } }); - test('should render all markdown elements correctly from server response', async ({ page, testWorkspace }) => { + test('should render all markdown elements correctly from server response', async ({ + page, + testWorkspace, + }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Navigate to Claude Code const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any blocking UI elements const toasts = page.locator('.fixed.bottom-4.right-4'); - if (await toasts.count() > 0) { + if ((await toasts.count()) > 0) { // Click outside to dismiss any toasts await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(1000); } - + // Force click the button if regular click doesn't work try { await claudeCodeButton.click({ timeout: 5000 }); @@ -44,14 +47,14 @@ test.describe('Claude Code Comprehensive Markdown Rendering', () => { await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Intercept the SSE response to capture what the server sends - let serverResponse = ''; - let capturedMessages: any[] = []; - + const serverResponse = ''; + const capturedMessages: any[] = []; + // Listen to network events to capture SSE data page.on('response', async (response) => { if (response.url().includes('/api/claude/chat') && response.request().method() === 'POST') { @@ -62,7 +65,7 @@ test.describe('Claude Code Comprehensive Markdown Rendering', () => { }); // Also monitor console for any markdown parsing errors - page.on('console', msg => { + page.on('console', (msg) => { if (msg.type() === 'error' && msg.text().includes('markdown')) { console.error('Markdown parsing error:', msg.text()); } @@ -110,91 +113,94 @@ End of markdown test.`; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for response to complete await page.waitForTimeout(2000); - + // Wait for assistant's response - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 3; - }, { timeout: 30000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 3; + }, + { timeout: 30000 } + ); + // Wait a bit more to ensure streaming is complete await page.waitForTimeout(3000); - + // Get the assistant's response const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); const responseElement = messages.nth(messageCount - 1); - + // Take screenshot for debugging await page.screenshot({ path: 'markdown-comprehensive-test.png', fullPage: true }); - + // Get the response content const responseText = await responseElement.textContent(); const responseHTML = await responseElement.innerHTML(); - + console.log('Response text:', responseText); console.log('Response HTML (first 500 chars):', responseHTML.substring(0, 500)); - + // Verify content is not [object Object] expect(responseText).not.toContain('[object Object]'); - + // Test 1: Headers are rendered (not showing # symbols) expect(responseText).not.toContain('# Header 1'); expect(responseText).not.toContain('## Header 2'); expect(responseText).not.toContain('### Header 3'); - + // Headers should be rendered as actual headers const h1Elements = await responseElement.locator('h1').count(); const h2Elements = await responseElement.locator('h2').count(); const h3Elements = await responseElement.locator('h3').count(); - + expect(h1Elements).toBeGreaterThan(0); expect(h2Elements).toBeGreaterThan(0); expect(h3Elements).toBeGreaterThan(0); - + // Test 2: Bold and italic text expect(responseText).not.toContain('**bold**'); expect(responseText).not.toContain('*italic*'); - + const strongElements = await responseElement.locator('strong').count(); const emElements = await responseElement.locator('em').count(); - + expect(strongElements).toBeGreaterThan(0); expect(emElements).toBeGreaterThan(0); - + // Test 3: Lists are rendered properly const ulElements = await responseElement.locator('ul').count(); const olElements = await responseElement.locator('ol').count(); const liElements = await responseElement.locator('li').count(); - + expect(ulElements).toBeGreaterThan(0); expect(olElements).toBeGreaterThan(0); expect(liElements).toBeGreaterThan(0); - + // Test 4: Checkboxes are rendered const checkboxes = await responseElement.locator('input[type="checkbox"]').count(); expect(checkboxes).toBe(2); // One checked, one unchecked - + // Verify one is checked and one is not const checkedBoxes = await responseElement.locator('input[type="checkbox"][checked]').count(); expect(checkedBoxes).toBe(1); - + // Test 5: Code blocks and inline code expect(responseText).not.toContain('```javascript'); expect(responseText).not.toContain('```'); - + const preElements = await responseElement.locator('pre').count(); const codeElements = await responseElement.locator('code').count(); - + expect(preElements).toBeGreaterThan(0); expect(codeElements).toBeGreaterThan(0); - + // Verify inline code doesn't show backticks const inlineCodeElements = await responseElement.locator('code').all(); for (const codeEl of inlineCodeElements) { @@ -205,52 +211,52 @@ End of markdown test.`; expect(codeText).not.toContain('`'); } } - + // Test 6: Blockquote const blockquoteElements = await responseElement.locator('blockquote').count(); expect(blockquoteElements).toBeGreaterThan(0); - + // Test 7: Link const linkElements = await responseElement.locator('a[href="https://example.com"]').count(); expect(linkElements).toBeGreaterThan(0); - + // Test 8: Horizontal rule const hrElements = await responseElement.locator('hr').count(); expect(hrElements).toBeGreaterThan(0); - + // Verify CSS classes are applied const h1Element = responseElement.locator('h1').first(); const h1Classes = await h1Element.getAttribute('class'); expect(h1Classes).toContain('text-2xl'); expect(h1Classes).toContain('font-bold'); - + const ulElement = responseElement.locator('ul').first(); const ulClasses = await ulElement.getAttribute('class'); expect(ulClasses).toContain('list-disc'); expect(ulClasses).toContain('list-inside'); - + console.log('All markdown elements rendered correctly!'); }); test('should handle edge cases in markdown rendering', async ({ page, testWorkspace }) => { await setupWorkspaceInBrowser(page, testWorkspace); - + // Navigate to Claude Code const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any blocking UI elements const toasts = page.locator('.fixed.bottom-4.right-4'); - if (await toasts.count() > 0) { + if ((await toasts.count()) > 0) { await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(1000); } - + // Force click if needed try { await claudeCodeButton.click({ timeout: 5000 }); @@ -261,10 +267,10 @@ End of markdown test.`; await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Test edge cases const edgeCaseMessage = `Please respond with EXACTLY this markdown: @@ -289,31 +295,34 @@ Mixed list: const messageInput = page.locator('[data-testid="message-input"]'); await messageInput.fill(edgeCaseMessage); await messageInput.press('Enter'); - + // Wait for response await page.waitForTimeout(2000); - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 3; - }, { timeout: 30000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 3; + }, + { timeout: 30000 } + ); + await page.waitForTimeout(3000); - + const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); const responseElement = messages.nth(messageCount - 1); - + const responseText = await responseElement.textContent(); - + // Verify edge cases render without errors expect(responseText).not.toContain('[object Object]'); expect(responseText).not.toContain('```'); - + // Check nested formatting works const nestedStrong = await responseElement.locator('strong:has(em)').count(); expect(nestedStrong).toBeGreaterThan(0); - + // Take screenshot await page.screenshot({ path: 'markdown-edge-cases-test.png', fullPage: true }); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-markdown-rendering.spec.ts b/apps/v1/client/e2e/test-markdown-rendering.spec.ts similarity index 90% rename from e2e/test-markdown-rendering.spec.ts rename to apps/v1/client/e2e/test-markdown-rendering.spec.ts index e737d2f3..73077353 100644 --- a/e2e/test-markdown-rendering.spec.ts +++ b/apps/v1/client/e2e/test-markdown-rendering.spec.ts @@ -16,98 +16,101 @@ test.describe('Claude Code Markdown Rendering', () => { test('should render markdown with bold text correctly', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load and SSE connection to establish await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Type the test message const testMessage = 'Say "Hello **world**!" with the word world in bold markdown'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for the response to complete await page.waitForTimeout(2000); // Give time for message to start - + // Wait for a new assistant message that contains "Hello" and check for completion // We should have at least 3 messages now: greeting, user message, response - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 3; - }, { timeout: 30000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 3; + }, + { timeout: 30000 } + ); + // Get the last message (assistant's response) const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); const responseElement = messages.nth(messageCount - 1); const responseHTML = await responseElement.innerHTML(); const responseText = await responseElement.textContent(); - + console.log('Response HTML:', responseHTML); console.log('Response Text:', responseText); - + // Check that the response doesn't contain [object Object] expect(responseText).not.toContain('[object Object]'); - + // Check that markdown is properly rendered // The word "world" should be in a tag if markdown is working const strongElements = await responseElement.locator('strong').all(); const hasStrongTag = strongElements.length > 0; - + if (hasStrongTag) { console.log('Markdown rendering is working - found tags'); // Check if "world" is bold - const worldIsStrong = await responseElement.locator('strong:has-text("world")').count() > 0; + const worldIsStrong = (await responseElement.locator('strong:has-text("world")').count()) > 0; expect(worldIsStrong).toBe(true); } else { // If no strong tags, check if the raw markdown is visible const hasRawMarkdown = responseText.includes('**world**'); console.log('Has raw markdown:', hasRawMarkdown); - + // This indicates markdown isn't being rendered if (hasRawMarkdown) { throw new Error('Markdown is not being rendered - raw ** symbols are visible'); } } - + // Take a screenshot for debugging await page.screenshot({ path: 'markdown-test-result.png', fullPage: true }); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-plan-mode.spec.ts b/apps/v1/client/e2e/test-plan-mode.spec.ts similarity index 81% rename from e2e/test-plan-mode.spec.ts rename to apps/v1/client/e2e/test-plan-mode.spec.ts index 1e32444a..08f12653 100644 --- a/e2e/test-plan-mode.spec.ts +++ b/apps/v1/client/e2e/test-plan-mode.spec.ts @@ -16,65 +16,79 @@ test.describe('Plan Mode Restrictions', () => { test('should not allow write operations in plan mode', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Verify we're in plan mode - check the mode toggle const planModeButton = page.locator('button:has-text("Planning")'); await expect(planModeButton).toHaveClass(/bg-green-600/); - + // Send a message requesting a write operation const messageInput = page.locator('[data-testid="message-input"]'); await messageInput.fill('Update the README.md file to add a section about testing'); await page.keyboard.press('Enter'); - + // Wait for the response await page.waitForTimeout(5000); // Give Claude time to respond - + // Check that no Write tools were executed - const writeTools = await page.locator('[data-testid="tool-execution"]:has-text("Write file")').count(); - const editTools = await page.locator('[data-testid="tool-execution"]:has-text("Edit file")').count(); - const bashTools = await page.locator('[data-testid="tool-execution"]:has-text("Run command")').count(); - + const writeTools = await page + .locator('[data-testid="tool-execution"]:has-text("Write file")') + .count(); + const editTools = await page + .locator('[data-testid="tool-execution"]:has-text("Edit file")') + .count(); + const bashTools = await page + .locator('[data-testid="tool-execution"]:has-text("Run command")') + .count(); + expect(writeTools).toBe(0); expect(editTools).toBe(0); expect(bashTools).toBe(0); - + // Check that only read tools were used - const readTools = await page.locator('[data-testid="tool-execution"]:has-text("Read file")').count(); - const searchTools = await page.locator('[data-testid="tool-execution"]:has-text("Search files")').count(); - const findTools = await page.locator('[data-testid="tool-execution"]:has-text("Find files")').count(); - const listTools = await page.locator('[data-testid="tool-execution"]:has-text("List directory")').count(); - + const readTools = await page + .locator('[data-testid="tool-execution"]:has-text("Read file")') + .count(); + const searchTools = await page + .locator('[data-testid="tool-execution"]:has-text("Search files")') + .count(); + const findTools = await page + .locator('[data-testid="tool-execution"]:has-text("Find files")') + .count(); + const listTools = await page + .locator('[data-testid="tool-execution"]:has-text("List directory")') + .count(); + // At least some read operations should have been performed const totalReadOperations = readTools + searchTools + findTools + listTools; expect(totalReadOperations).toBeGreaterThan(0); - + // Check that the response mentions planning or suggests changes without implementing const assistantMessages = page.locator('[data-testid="message-bubble"][data-role="assistant"]'); const lastAssistantMessage = assistantMessages.last(); const messageText = await lastAssistantMessage.textContent(); - + // The message should indicate planning mode behavior expect(messageText?.toLowerCase()).toMatch(/plan|suggest|would|could|should|propose/); - + // Take a screenshot for debugging await page.screenshot({ path: 'plan-mode-test.png', fullPage: true }); }); @@ -82,36 +96,40 @@ test.describe('Plan Mode Restrictions', () => { test('should show exit plan mode tool when appropriate', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a message that should trigger exit plan mode const messageInput = page.locator('[data-testid="message-input"]'); await messageInput.fill('Create a plan to refactor the authentication module'); await page.keyboard.press('Enter'); - + // Wait for response with exit_plan_mode tool - await page.waitForSelector('[data-testid="tool-execution"]:has-text("Exit plan mode")', { timeout: 30000 }); - + await page.waitForSelector('[data-testid="tool-execution"]:has-text("Exit plan mode")', { + timeout: 30000, + }); + // Verify the exit plan mode tool was executed - const exitPlanTools = await page.locator('[data-testid="tool-execution"]:has-text("Exit plan mode")').count(); + const exitPlanTools = await page + .locator('[data-testid="tool-execution"]:has-text("Exit plan mode")') + .count(); expect(exitPlanTools).toBeGreaterThan(0); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-setup.ts b/apps/v1/client/e2e/test-setup.ts similarity index 80% rename from e2e/test-setup.ts rename to apps/v1/client/e2e/test-setup.ts index d79134f1..1152a30b 100644 --- a/e2e/test-setup.ts +++ b/apps/v1/client/e2e/test-setup.ts @@ -10,47 +10,50 @@ export const test = base.extend<{ testWorkspace: async ({}, use) => { // Create a temporary test workspace const testDir = path.join(os.tmpdir(), `e2e-test-workspace-${Date.now()}`); - + try { console.log(`Creating test workspace at: ${testDir}`); - + // Create the workspace via API const createResponse = await fetch('http://localhost:3000/api/workspace/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspacePath: testDir }) + body: JSON.stringify({ workspacePath: testDir }), }); - + if (!createResponse.ok) { throw new Error(`Failed to create test workspace: ${createResponse.statusText}`); } - + // Create a test project const projectResponse = await fetch('http://localhost:3000/api/workspace/create-project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspacePath: testDir, - projectName: 'Test Project' - }) + body: JSON.stringify({ + workspacePath: testDir, + projectName: 'Test Project', + }), }); - + if (!projectResponse.ok) { throw new Error(`Failed to create test project: ${projectResponse.statusText}`); } - + // Create a simple test repo directory manually const projectPath = path.join(testDir, 'projects', 'test-project'); const reposPath = path.join(projectPath, 'repos'); await fs.mkdir(reposPath, { recursive: true }); - + // Create a fake repo directory for testing const testRepoPath = path.join(reposPath, 'hello-world-1'); await fs.mkdir(testRepoPath, { recursive: true }); - + // Create a simple README.md file - await fs.writeFile(path.join(testRepoPath, 'README.md'), '# Hello World\nThis is a test repository.'); - + await fs.writeFile( + path.join(testRepoPath, 'README.md'), + '# Hello World\nThis is a test repository.' + ); + // Update the project's REPOS.md file const reposMdContent = `# Repository usage @@ -69,7 +72,7 @@ export const test = base.extend<{ (No active work) `; await fs.writeFile(path.join(projectPath, 'REPOS.md'), reposMdContent); - + await use(testDir); } finally { // Cleanup: remove the test workspace @@ -89,32 +92,40 @@ export { expect }; export async function setupWorkspaceInBrowser(page: any, workspacePath: string) { // Set the workspace in localStorage to bypass the workspace dialog await page.addInitScript((path: string) => { - localStorage.setItem('workspaceConfig', JSON.stringify({ - path: path, - name: path.split('/').pop() || 'Test Workspace' - })); + localStorage.setItem( + 'workspaceConfig', + JSON.stringify({ + path: path, + name: path.split('/').pop() || 'Test Workspace', + }) + ); }, workspacePath); - + // Navigate to the app await page.goto('http://localhost:5173'); - + // Wait for the initial DOM to be ready await page.waitForLoadState('domcontentloaded'); - + // Wait for the workspace to load by checking if we're past the workspace selection - await page.waitForFunction(() => { - // Check if we're past the workspace selection screen - return !document.querySelector('[data-testid="workspace-dialog"]') && - !document.body.textContent?.includes('Select workspace'); - }, { timeout: 10000 }); - + await page.waitForFunction( + () => { + // Check if we're past the workspace selection screen + return ( + !document.querySelector('[data-testid="workspace-dialog"]') && + !document.body.textContent?.includes('Select workspace') + ); + }, + { timeout: 10000 } + ); + // Navigate to projects page if not already there const currentUrl = page.url(); if (!currentUrl.includes('/projects')) { await page.goto('http://localhost:5173/projects'); await page.waitForLoadState('domcontentloaded'); } - + // Wait for projects page to be ready await page.waitForSelector('[data-testid="project-card"]', { timeout: 10000 }); -} \ No newline at end of file +} diff --git a/e2e/test-streaming-cursor.spec.ts b/apps/v1/client/e2e/test-streaming-cursor.spec.ts similarity index 91% rename from e2e/test-streaming-cursor.spec.ts rename to apps/v1/client/e2e/test-streaming-cursor.spec.ts index 7f73ae55..3c963217 100644 --- a/e2e/test-streaming-cursor.spec.ts +++ b/apps/v1/client/e2e/test-streaming-cursor.spec.ts @@ -16,64 +16,67 @@ test.describe('Claude Code Streaming Cursor', () => { test('should show and hide streaming cursor correctly', async ({ page, testWorkspace }) => { // Set up workspace and navigate to Claude Code await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any blocking UI elements const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a simple message const testMessage = 'Please say "Hello" and nothing else'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for response to start streaming await page.waitForTimeout(1500); - + // Wait for a new message to appear - await page.waitForFunction(() => { - const messages = document.querySelectorAll('[data-testid="message-bubble"]'); - return messages.length >= 3; // greeting + user + assistant - }, { timeout: 30000 }); - + await page.waitForFunction( + () => { + const messages = document.querySelectorAll('[data-testid="message-bubble"]'); + return messages.length >= 3; // greeting + user + assistant + }, + { timeout: 30000 } + ); + // Get the streaming message const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); const assistantMessage = messages.nth(messageCount - 1); - + // Check for streaming cursor (should be visible while streaming) const streamingCursor = assistantMessage.locator('.animate-pulse'); - + // At some point during streaming, the cursor should be visible let cursorWasVisible = false; for (let i = 0; i < 10; i++) { @@ -85,76 +88,76 @@ test.describe('Claude Code Streaming Cursor', () => { } await page.waitForTimeout(500); } - + // If we never saw the cursor, the message might have completed too quickly // Let's check the debug info const debugInfo = assistantMessage.locator('.text-xs.text-gray-400'); - if (await debugInfo.count() > 0) { + if ((await debugInfo.count()) > 0) { const debugText = await debugInfo.textContent(); console.log('Debug info:', debugText); } - + // Wait for streaming to complete (up to 10 seconds) await page.waitForTimeout(5000); - + // After streaming completes, cursor should be gone const finalCursorCount = await streamingCursor.count(); console.log('Final cursor count:', finalCursorCount); - + // Get debug info again to check final streaming state - if (await debugInfo.count() > 0) { + if ((await debugInfo.count()) > 0) { const finalDebugText = await debugInfo.textContent(); console.log('Final debug info:', finalDebugText); - + // Check that streaming is false expect(finalDebugText).toContain('Streaming: false'); } - + // Cursor should no longer be visible expect(finalCursorCount).toBe(0); - + // Take screenshot for debugging await page.screenshot({ path: 'streaming-cursor-test.png', fullPage: true }); }); test('should properly handle multiple rapid messages', async ({ page, testWorkspace }) => { await setupWorkspaceInBrowser(page, testWorkspace); - + // Navigate to Claude Code const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - + // Wait for greeting await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + const messageInput = page.locator('[data-testid="message-input"]'); - + // Send first message await messageInput.fill('Say "One"'); await messageInput.press('Enter'); - + // Wait a bit for first response to start await page.waitForTimeout(2000); - + // Send second message while first might still be streaming await messageInput.fill('Say "Two"'); await messageInput.press('Enter'); - + // Wait for both responses await page.waitForTimeout(8000); - + // Check that no messages are still showing streaming cursor const allMessages = page.locator('[data-testid="message-bubble"]'); const totalMessages = await allMessages.count(); - + let streamingMessages = 0; for (let i = 0; i < totalMessages; i++) { const message = allMessages.nth(i); @@ -162,17 +165,17 @@ test.describe('Claude Code Streaming Cursor', () => { const cursorCount = await cursor.count(); if (cursorCount > 0) { streamingMessages++; - + // Get debug info for streaming message const debugInfo = message.locator('.text-xs.text-gray-400'); - if (await debugInfo.count() > 0) { + if ((await debugInfo.count()) > 0) { const debugText = await debugInfo.textContent(); console.log(`Message ${i} still streaming:`, debugText); } } } - + // No messages should still be streaming expect(streamingMessages).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-streaming-events.spec.ts b/apps/v1/client/e2e/test-streaming-events.spec.ts similarity index 81% rename from e2e/test-streaming-events.spec.ts rename to apps/v1/client/e2e/test-streaming-events.spec.ts index 3cf664c2..b4a42613 100644 --- a/e2e/test-streaming-events.spec.ts +++ b/apps/v1/client/e2e/test-streaming-events.spec.ts @@ -16,80 +16,81 @@ test.describe('Claude Code Streaming Events', () => { test('should log all SSE events', async ({ page, testWorkspace }) => { // Capture console logs const consoleLogs: string[] = []; - page.on('console', msg => { + page.on('console', (msg) => { const text = msg.text(); consoleLogs.push(text); if (text.includes('event') || text.includes('message-') || text.includes('SSE')) { console.log('Browser console:', text); } }); - + // Set up workspace await setupWorkspaceInBrowser(page, testWorkspace); - + // Navigate to Claude Code const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss toasts const toasts = page.locator('.fixed.bottom-4.right-4'); - if (await toasts.count() > 0) { + if ((await toasts.count()) > 0) { await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); - + // Wait for greeting await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a simple message const messageInput = page.locator('[data-testid="message-input"]'); await messageInput.fill('Say "test"'); await messageInput.press('Enter'); - + // Wait for response await page.waitForTimeout(8000); - + // Check console logs for events console.log('\n=== SSE Events Received ==='); - const eventLogs = consoleLogs.filter(log => - log.includes('event received') || - log.includes('SSE') || - log.includes('message-start') || - log.includes('message-chunk') || - log.includes('message-end') || - log.includes('message-complete') + const eventLogs = consoleLogs.filter( + (log) => + log.includes('event received') || + log.includes('SSE') || + log.includes('message-start') || + log.includes('message-chunk') || + log.includes('message-end') || + log.includes('message-complete') ); - - eventLogs.forEach(log => console.log(log)); - + + eventLogs.forEach((log) => console.log(log)); + // Check if message-end was received - const hasMessageEnd = eventLogs.some(log => log.includes('message-end')); - const hasMessageComplete = eventLogs.some(log => log.includes('message-complete')); - + const hasMessageEnd = eventLogs.some((log) => log.includes('message-end')); + const hasMessageComplete = eventLogs.some((log) => log.includes('message-complete')); + console.log('\nHas message-end event:', hasMessageEnd); console.log('Has message-complete event:', hasMessageComplete); - + // Check final message state const messages = page.locator('[data-testid="message-bubble"]'); const messageCount = await messages.count(); const lastMessage = messages.nth(messageCount - 1); - + const debugInfo = lastMessage.locator('.text-xs.text-gray-400'); - if (await debugInfo.count() > 0) { + if ((await debugInfo.count()) > 0) { const debugText = await debugInfo.textContent(); console.log('\nFinal message state:', debugText); } - + // Should have received either message-end or message-complete expect(hasMessageEnd || hasMessageComplete).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-tool-execution-timing.spec.ts b/apps/v1/client/e2e/test-tool-execution-timing.spec.ts similarity index 95% rename from e2e/test-tool-execution-timing.spec.ts rename to apps/v1/client/e2e/test-tool-execution-timing.spec.ts index 5bec12b9..48165995 100644 --- a/e2e/test-tool-execution-timing.spec.ts +++ b/apps/v1/client/e2e/test-tool-execution-timing.spec.ts @@ -16,60 +16,60 @@ test.describe('Tool Execution Timing', () => { test('should show tools as running then complete', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Type a message that will trigger tool use const testMessage = 'List the files in the current directory'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for tool execution to appear await page.waitForSelector('[data-testid="tool-execution"]', { timeout: 10000 }); - + // Get the tool execution element const toolExecution = page.locator('[data-testid="tool-execution"]').first(); - + // Check that the tool shows running status with spinner const runningSpinner = toolExecution.locator('.animate-spin'); await expect(runningSpinner).toBeVisible({ timeout: 5000 }); - + // Verify the tool name is displayed const toolName = toolExecution.locator('[data-testid="tool-name"]'); await expect(toolName).toContainText('List directory'); - + // Wait for the tool to complete (spinner should disappear and checkmark should appear) await expect(runningSpinner).not.toBeVisible({ timeout: 10000 }); - + // Check for completion checkmark const completionIcon = toolExecution.locator('svg path[d="M5 13l4 4L19 7"]'); await expect(completionIcon).toBeVisible(); - + // Verify execution time is displayed const executionTime = toolExecution.locator('text=/\\d+(\\.\\d+)?[ms|s]/'); await expect(executionTime).toBeVisible(); - + // Take a screenshot showing completed tool await page.screenshot({ path: 'tool-execution-complete.png', fullPage: true }); }); @@ -77,77 +77,79 @@ test.describe('Tool Execution Timing', () => { test('should show multiple tools executing in sequence', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Send a message that will trigger multiple tools const testMessage = 'Find all TypeScript files and read the first one you find'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); await messageInput.press('Enter'); - + // Wait for first tool (Glob/Find files) await page.waitForSelector('[data-testid="tool-execution"]', { timeout: 10000 }); - + // Count tools as they appear let toolCount = 0; const maxWaitTime = 30000; const startTime = Date.now(); - + while (Date.now() - startTime < maxWaitTime) { const tools = page.locator('[data-testid="tool-execution"]'); const currentCount = await tools.count(); - + if (currentCount > toolCount) { toolCount = currentCount; console.log(`Tool ${toolCount} appeared`); - + // Check that each new tool initially shows running status const latestTool = tools.nth(toolCount - 1); const spinner = latestTool.locator('.animate-spin'); - + // New tools should show spinner initially if (await spinner.isVisible()) { console.log(`Tool ${toolCount} is running (has spinner)`); } } - + // Check if we have at least 2 tools (Find files + Read file) if (toolCount >= 2) { // Verify first tool is complete const firstTool = tools.first(); - const firstToolComplete = await firstTool.locator('svg path[d="M5 13l4 4L19 7"]').isVisible(); - + const firstToolComplete = await firstTool + .locator('svg path[d="M5 13l4 4L19 7"]') + .isVisible(); + if (firstToolComplete) { console.log('First tool completed successfully'); break; } } - + await page.waitForTimeout(100); } - + // Verify we got multiple tools expect(toolCount).toBeGreaterThanOrEqual(2); - + // All tools should eventually complete const tools = page.locator('[data-testid="tool-execution"]'); for (let i = 0; i < toolCount; i++) { @@ -155,8 +157,8 @@ test.describe('Tool Execution Timing', () => { const checkmark = tool.locator('svg path[d="M5 13l4 4L19 7"]'); await expect(checkmark).toBeVisible({ timeout: 10000 }); } - + // Take a screenshot showing multiple completed tools await page.screenshot({ path: 'multiple-tools-complete.png', fullPage: true }); }); -}); \ No newline at end of file +}); diff --git a/e2e/test-user-message-no-markdown.spec.ts b/apps/v1/client/e2e/test-user-message-no-markdown.spec.ts similarity index 94% rename from e2e/test-user-message-no-markdown.spec.ts rename to apps/v1/client/e2e/test-user-message-no-markdown.spec.ts index d86a64e4..c005f652 100644 --- a/e2e/test-user-message-no-markdown.spec.ts +++ b/apps/v1/client/e2e/test-user-message-no-markdown.spec.ts @@ -16,84 +16,86 @@ test.describe('User Message No Markdown Rendering', () => { test('should not render markdown in user messages', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); - + // Dismiss any panels or toasts that might be blocking const themeSwitcher = page.locator('text="Theme Switcher"'); - if (await themeSwitcher.count() > 0) { + if ((await themeSwitcher.count()) > 0) { await page.click('body', { position: { x: 10, y: 10 } }); await page.waitForTimeout(500); } - + const toasts = page.locator('[role="alert"], .fixed.bottom-4.right-4'); const toastCount = await toasts.count(); if (toastCount > 0) { const closeButton = toasts.locator('button').first(); - if (await closeButton.count() > 0) { + if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }); } await page.waitForTimeout(1000); } - + await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); // Wait for SSE setup - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // Type a message with special characters that would break if rendered as markdown const testMessage = 'Let\'s plan on a new "inspect @" feature'; const messageInput = page.locator('[data-testid="message-input"]'); await expect(messageInput).toBeVisible(); await messageInput.fill(testMessage); - + // Send the message await messageInput.press('Enter'); - + // Wait for the user message to appear await page.waitForTimeout(1000); - + // Find the user message (should be the second message after greeting) const messages = page.locator('[data-testid="message-bubble"]'); await page.waitForTimeout(2000); // Wait for message to appear const messageCount = await messages.count(); expect(messageCount).toBeGreaterThanOrEqual(2); - + // Get the user message (second bubble) const userMessage = messages.nth(1); const userMessageText = await userMessage.textContent(); - + // Verify the message text is displayed correctly with angle brackets - expect(userMessageText).toContain('Let\'s plan on a new "inspect @" feature'); - + expect(userMessageText).toContain( + 'Let\'s plan on a new "inspect @" feature' + ); + // Verify there are no HTML elements that would indicate markdown rendering const userMessageHTML = await userMessage.innerHTML(); - + // Check that angle brackets are preserved and not interpreted as HTML expect(userMessageHTML).toContain('<packagename>'); expect(userMessageHTML).toContain('<version>'); - + // Check that the message doesn't have markdown-specific classes const markdownContent = await userMessage.locator('.markdown-content').count(); expect(markdownContent).toBe(0); - + // Check that the content is in a simple div with whitespace preservation const simpleContent = await userMessage.locator('.whitespace-pre-wrap').count(); expect(simpleContent).toBe(1); - + // Take a screenshot for debugging await page.screenshot({ path: 'user-message-no-markdown.png', fullPage: true }); }); @@ -101,35 +103,35 @@ test.describe('User Message No Markdown Rendering', () => { test('should render markdown in assistant messages', async ({ page, testWorkspace }) => { // Set up workspace and navigate to projects page await setupWorkspaceInBrowser(page, testWorkspace); - + // Click on test project const projectCard = page.locator('[data-testid="project-card"]').first(); await expect(projectCard).toBeVisible({ timeout: 10000 }); await projectCard.click(); await page.waitForSelector('[data-testid="repo-card"]', { timeout: 10000 }); - + // Click on Claude Code button const claudeCodeButton = page.locator('[data-testid="claude-code-button"]').first(); await expect(claudeCodeButton).toBeVisible({ timeout: 10000 }); await claudeCodeButton.click(); - + // Wait for Claude Code page to load await page.waitForURL('**/claude-code/**'); await page.waitForSelector('[data-testid="message-list"]', { timeout: 10000 }); await page.waitForTimeout(1000); - + // Wait for greeting message await page.waitForSelector('[data-testid="message-bubble"]', { timeout: 30000 }); - + // The greeting message should have markdown rendering const greetingMessage = page.locator('[data-testid="message-bubble"]').first(); - + // Check that assistant messages have markdown-content class const markdownContent = await greetingMessage.locator('.markdown-content').count(); expect(markdownContent).toBe(1); - + // Check that it doesn't have the plain text class const plainContent = await greetingMessage.locator('.whitespace-pre-wrap').count(); expect(plainContent).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/apps/v1/client/eslint.config.js b/apps/v1/client/eslint.config.js new file mode 100644 index 00000000..9a86baef --- /dev/null +++ b/apps/v1/client/eslint.config.js @@ -0,0 +1,70 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['build/**', 'dist/**', 'lib/**', 'lib-commonjs/**', 'node_modules/**'], + }, + // Config for CommonJS files + { + files: ['**/*.cjs', 'tailwind.config.js', 'postcss.config.cjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + ...globals.node, + ...globals.commonjs, + }, + }, + }, + { + files: ['**/*.{ts,tsx,js,jsx}'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + // Disable project mode for v1 client + project: false, + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + // Temporarily disable no-explicit-any for v1 code + '@typescript-eslint/no-explicit-any': 'off', + // Disable unused vars for v1 code entirely (too many false positives) + '@typescript-eslint/no-unused-vars': 'off', + // Allow require() in config files + '@typescript-eslint/no-require-imports': 'off', + // Disable some rules that are too strict for v1 + 'no-useless-escape': 'off', + 'no-case-declarations': 'off', + // Disable react refresh warnings for v1 + 'react-refresh/only-export-components': 'off', + // Disable react hooks warnings for v1 + 'react-hooks/exhaustive-deps': 'off', + 'react-hooks/rules-of-hooks': 'off', + // Allow empty patterns for v1 + 'no-empty-pattern': 'off', + // Disable one-export-per-file for v1 + '@claude-flow/one-export-per-file': 'off', + }, + } +); diff --git a/index.html b/apps/v1/client/index.html similarity index 100% rename from index.html rename to apps/v1/client/index.html diff --git a/apps/v1/client/package.json b/apps/v1/client/package.json new file mode 100644 index 00000000..6bc16032 --- /dev/null +++ b/apps/v1/client/package.json @@ -0,0 +1,59 @@ +{ + "name": "@claude-flow/v1-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tcm src && concurrently --kill-others \"vite\" \"tcm src --watch\"", + "dev:host": "tcm src && concurrently --kill-others \"vite --host\" \"tcm src --watch\"", + "dev:vite-only": "vite", + "build": "tcm src && tsc -b && vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "preview": "vite preview", + "clean": "repo-scripts clean", + "test": "echo 'Run test:e2e for tests'", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "typecheck": "tcm src && tsc --noEmit", + "tcm": "tcm src", + "tcm:watch": "tcm src --watch" + }, + "dependencies": { + "@mdxeditor/editor": "^3.39.0", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-virtual": "^3.13.12", + "date-fns": "^4.1.0", + "diff": "^8.0.2", + "dom-to-image": "^2.6.0", + "eventsource": "^4.0.0", + "marked": "^16.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.3", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@playwright/test": "^1.53.2", + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/postcss": "^4.1.11", + "@types/diff": "^7.0.2", + "@types/dom-to-image": "^2.6.7", + "@types/node": "^24.0.10", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.21", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } +} diff --git a/playwright.config.ts b/apps/v1/client/playwright.config.ts similarity index 97% rename from playwright.config.ts rename to apps/v1/client/playwright.config.ts index 89b2621d..003df121 100644 --- a/playwright.config.ts +++ b/apps/v1/client/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { + use: { ...devices['Desktop Chrome'], launchOptions: { args: ['--no-sandbox', '--disable-setuid-sandbox'], @@ -26,4 +26,4 @@ export default defineConfig({ }, }, ], -}); \ No newline at end of file +}); diff --git a/postcss.config.cjs b/apps/v1/client/postcss.config.cjs similarity index 96% rename from postcss.config.cjs rename to apps/v1/client/postcss.config.cjs index 96bb01e7..12a703d9 100644 --- a/postcss.config.cjs +++ b/apps/v1/client/postcss.config.cjs @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/apps/v1/client/public/oauth-callback.html b/apps/v1/client/public/oauth-callback.html new file mode 100644 index 00000000..195774ee --- /dev/null +++ b/apps/v1/client/public/oauth-callback.html @@ -0,0 +1,116 @@ + + + + GitHub OAuth Callback + + + +
+
+

Completing GitHub authentication...

+

Please wait while we complete the sign-in process.

+
+ + + + diff --git a/public/vite.svg b/apps/v1/client/public/vite.svg similarity index 100% rename from public/vite.svg rename to apps/v1/client/public/vite.svg diff --git a/src/App.tsx b/apps/v1/client/src/App.tsx similarity index 97% rename from src/App.tsx rename to apps/v1/client/src/App.tsx index db408cad..ede0e250 100644 --- a/src/App.tsx +++ b/apps/v1/client/src/App.tsx @@ -48,10 +48,7 @@ function AppContent() { return ( <> - + {workspace.config && ( <> @@ -110,4 +107,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/assets/react.svg b/apps/v1/client/src/assets/react.svg similarity index 100% rename from src/assets/react.svg rename to apps/v1/client/src/assets/react.svg diff --git a/src/components/AnimatedOutlet.tsx b/apps/v1/client/src/components/AnimatedOutlet.tsx similarity index 80% rename from src/components/AnimatedOutlet.tsx rename to apps/v1/client/src/components/AnimatedOutlet.tsx index 2a3664f2..2ffdc7a4 100644 --- a/src/components/AnimatedOutlet.tsx +++ b/apps/v1/client/src/components/AnimatedOutlet.tsx @@ -11,7 +11,7 @@ export function AnimatedOutlet() { const location = useLocation(); const currentOutlet = useOutlet(); const [items, setItems] = useState([ - { key: location.pathname, element: currentOutlet, state: 'active' } + { key: location.pathname, element: currentOutlet, state: 'active' }, ]); const timeoutRef = useRef | undefined>(undefined); const isFirstRender = useRef(true); @@ -30,27 +30,23 @@ export function AnimatedOutlet() { // Always animate on route change const newKey = `${location.pathname}-${Date.now()}`; - + // Mark existing as exiting and add new as entering - setItems(current => { - const exiting = current.map(item => ({ ...item, state: 'exiting' as const })); + setItems((current) => { + const exiting = current.map((item) => ({ ...item, state: 'exiting' as const })); return [...exiting, { key: newKey, element: currentOutlet, state: 'entering' }]; }); // Immediately activate the entering item requestAnimationFrame(() => { - setItems(current => - current.map(item => - item.key === newKey - ? { ...item, state: 'active' } - : item - ) + setItems((current) => + current.map((item) => (item.key === newKey ? { ...item, state: 'active' } : item)) ); }); // Clean up exiting items after animation timeoutRef.current = setTimeout(() => { - setItems(current => current.filter(item => item.state === 'active')); + setItems((current) => current.filter((item) => item.state === 'active')); }, 450); return () => { @@ -68,15 +64,15 @@ export function AnimatedOutlet() { className="absolute inset-0 transition-all duration-300 ease-out" style={{ opacity: item.state === 'active' ? 1 : 0, - transform: - item.state === 'active' - ? 'translateX(0)' + transform: + item.state === 'active' + ? 'translateX(0)' : item.state === 'entering' ? 'translateX(20px)' : 'translateX(-20px)', transitionDelay: item.state === 'entering' ? '200ms' : '0ms', pointerEvents: item.state === 'active' ? 'auto' : 'none', - zIndex: item.state === 'active' ? 2 : 1 + zIndex: item.state === 'active' ? 2 : 1, }} > {item.element} @@ -84,4 +80,4 @@ export function AnimatedOutlet() { ))}
); -} \ No newline at end of file +} diff --git a/src/components/AnimatedOutletWrapper.tsx b/apps/v1/client/src/components/AnimatedOutletWrapper.tsx similarity index 89% rename from src/components/AnimatedOutletWrapper.tsx rename to apps/v1/client/src/components/AnimatedOutletWrapper.tsx index 5fa55a8d..14ce5629 100644 --- a/src/components/AnimatedOutletWrapper.tsx +++ b/apps/v1/client/src/components/AnimatedOutletWrapper.tsx @@ -8,15 +8,15 @@ interface AnimatedOutletWrapperProps { distance?: number; } -export function AnimatedOutletWrapper({ - className = '', +export function AnimatedOutletWrapper({ + className = '', delay = 200, - distance = 20 + distance = 20, }: AnimatedOutletWrapperProps) { const location = useLocation(); const outlet = useOutlet(); const direction = useNavigationDirection(); - + return ( ); -} \ No newline at end of file +} diff --git a/src/components/AnimatedTransition.tsx b/apps/v1/client/src/components/AnimatedTransition.tsx similarity index 78% rename from src/components/AnimatedTransition.tsx rename to apps/v1/client/src/components/AnimatedTransition.tsx index fea14366..c7f5a5c8 100644 --- a/src/components/AnimatedTransition.tsx +++ b/apps/v1/client/src/components/AnimatedTransition.tsx @@ -18,31 +18,31 @@ interface AnimationItem { timestamp: number; } -export function AnimatedTransition({ - children, - transitionKey, +export function AnimatedTransition({ + children, + transitionKey, className = '', delay = 100, distance = 20, reverse = false, - centered = true + centered = true, }: AnimatedTransitionProps) { const [items, setItems] = useState([ { key: transitionKey, content: children, state: 'active', - timestamp: Date.now() - } + timestamp: Date.now(), + }, ]); - + const prevKeyRef = useRef(transitionKey); const timeoutsRef = useRef>(new Map()); // Cleanup timeouts on unmount useEffect(() => { return () => { - timeoutsRef.current.forEach(timeout => clearTimeout(timeout)); + timeoutsRef.current.forEach((timeout) => clearTimeout(timeout)); timeoutsRef.current.clear(); }; }, []); @@ -51,12 +51,8 @@ export function AnimatedTransition({ // Skip if key hasn't changed if (prevKeyRef.current === transitionKey) { // Update the content of the active item without animation - setItems(current => - current.map(item => - item.state === 'active' - ? { ...item, content: children } - : item - ) + setItems((current) => + current.map((item) => (item.state === 'active' ? { ...item, content: children } : item)) ); return; } @@ -67,10 +63,10 @@ export function AnimatedTransition({ const newKey = `${transitionKey}-${Date.now()}`; // Mark all existing items as exiting - setItems(current => { - const exitingItems = current.map(item => ({ + setItems((current) => { + const exitingItems = current.map((item) => ({ ...item, - state: 'exiting' as const + state: 'exiting' as const, })); // Add new item as entering @@ -78,7 +74,7 @@ export function AnimatedTransition({ key: newKey, content: children, state: 'entering', - timestamp: Date.now() + timestamp: Date.now(), }; return [...exitingItems, newItem]; @@ -92,12 +88,8 @@ export function AnimatedTransition({ // Transition new item to active after delay const activateTimeout = setTimeout(() => { - setItems(current => - current.map(item => - item.key === newKey - ? { ...item, state: 'active' } - : item - ) + setItems((current) => + current.map((item) => (item.key === newKey ? { ...item, state: 'active' } : item)) ); }, delay); @@ -105,26 +97,23 @@ export function AnimatedTransition({ // Remove exiting items after animation completes const cleanupTimeout = setTimeout(() => { - setItems(current => - current.filter(item => item.state !== 'exiting') - ); - + setItems((current) => current.filter((item) => item.state !== 'exiting')); + // Clean up timeout references timeoutsRef.current.delete(`${newKey}-activate`); timeoutsRef.current.delete(`${newKey}-cleanup`); }, 300 + delay); // 300ms for exit animation + delay timeoutsRef.current.set(`${newKey}-cleanup`, cleanupTimeout); - }, [transitionKey, children, delay]); - const containerClasses = centered + const containerClasses = centered ? `relative flex items-center justify-center ${className}` : `relative ${className}`; - + const contentClasses = centered - ? "absolute inset-0 flex items-center justify-center" - : "absolute inset-0"; + ? 'absolute inset-0 flex items-center justify-center' + : 'absolute inset-0'; const getTransform = (state: AnimationItem['state'], reverse: boolean) => { switch (state) { @@ -147,7 +136,7 @@ export function AnimatedTransition({ opacity: item.state === 'active' ? 1 : 0, transform: getTransform(item.state, reverse), transition: `all 300ms cubic-bezier(0.4, 0, 0.2, 1)`, - pointerEvents: item.state === 'active' ? 'auto' : 'none' + pointerEvents: item.state === 'active' ? 'auto' : 'none', }} > {item.content} @@ -155,4 +144,4 @@ export function AnimatedTransition({ ))} ); -} \ No newline at end of file +} diff --git a/apps/v1/client/src/components/AuthAvatar.tsx b/apps/v1/client/src/components/AuthAvatar.tsx new file mode 100644 index 00000000..6def06bf --- /dev/null +++ b/apps/v1/client/src/components/AuthAvatar.tsx @@ -0,0 +1,212 @@ +import { useState, useRef, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContextV2'; +import { DropdownTransition } from './DropdownTransition'; + +export function AuthAvatar() { + const { authState, activeAccount, signInWithGitHub, signOut, switchAccount } = useAuth(); + const { currentStyles } = useTheme(); + const styles = currentStyles; + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const handleSignIn = async () => { + try { + await signInWithGitHub(); + setIsOpen(false); + } catch (error) { + console.error('Failed to sign in:', error); + } + }; + + const handleSignOut = async (accountId: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await signOut(accountId); + if (authState.accounts.length === 1) { + setIsOpen(false); + } + } catch (error) { + console.error('Failed to sign out:', error); + } + }; + + const handleSwitchAccount = (accountId: string) => { + switchAccount(accountId); + setIsOpen(false); + }; + + return ( +
+ {/* Avatar Button */} + + + {/* Dropdown Menu */} + +
+ {/* Header */} +
+

GitHub Accounts

+
+ + {/* Account List */} + {authState.accounts.length > 0 ? ( +
+ {authState.accounts.map((account) => ( +
handleSwitchAccount(account.id)} + className={` + px-4 py-3 cursor-pointer + ${account.id === authState.activeAccountId ? styles.contentBg : ''} + hover:${styles.contentBg} transition-colors + border-b ${styles.contentBorder} last:border-b-0 + `} + > +
+
+ {account.username} +
+
{account.username}
+
+ {account.accountType === 'enterprise' ? 'GitHub Enterprise' : 'Personal'} +
+
+
+
+ {account.id === authState.activeAccountId && ( + + Active + + )} + +
+
+
+ ))} +
+ ) : ( +
+ + + +

No GitHub accounts connected

+
+ )} + + {/* Add Account Button */} +
+ +
+
+
+
+ ); +} diff --git a/src/components/AvatarGenerator.tsx b/apps/v1/client/src/components/AvatarGenerator.tsx similarity index 77% rename from src/components/AvatarGenerator.tsx rename to apps/v1/client/src/components/AvatarGenerator.tsx index 987fe359..79d950ed 100644 --- a/src/components/AvatarGenerator.tsx +++ b/apps/v1/client/src/components/AvatarGenerator.tsx @@ -7,9 +7,30 @@ interface AvatarProps { } const randomNames = [ - 'Alex', 'Blake', 'Casey', 'Drew', 'Emery', 'Finley', 'Gray', 'Harper', - 'Indigo', 'Jamie', 'Kai', 'Logan', 'Morgan', 'Nova', 'Oakley', 'Phoenix', - 'Quinn', 'River', 'Sage', 'Taylor', 'Unity', 'Vale', 'Winter', 'Zion' + 'Alex', + 'Blake', + 'Casey', + 'Drew', + 'Emery', + 'Finley', + 'Gray', + 'Harper', + 'Indigo', + 'Jamie', + 'Kai', + 'Logan', + 'Morgan', + 'Nova', + 'Oakley', + 'Phoenix', + 'Quinn', + 'River', + 'Sage', + 'Taylor', + 'Unity', + 'Vale', + 'Winter', + 'Zion', ]; // Simple hash function to get consistent random values from seed @@ -17,7 +38,7 @@ function hashCode(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; + hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash); @@ -42,25 +63,35 @@ export function AvatarGenerator({ seed, size = 100, isDarkMode = false }: Avatar // Skin colors (warm tones) const skinColors = ['#FFE0BD', '#FFCD94', '#F3C9A3', '#DDB192', '#D4A574', '#C58C5F']; const skinColor = getRandomFromSeed(seed + 'skin', skinColors); - + // Hair colors - const hairColors = ['#2C1810', '#3D2314', '#5A3825', '#8B5A3C', '#D2691E', '#FFD700', '#FF6B6B', '#A8E6CF', '#9370DB']; + const hairColors = [ + '#2C1810', + '#3D2314', + '#5A3825', + '#8B5A3C', + '#D2691E', + '#FFD700', + '#FF6B6B', + '#A8E6CF', + '#9370DB', + ]; const hairColor = getRandomFromSeed(seed + 'hair', hairColors); - + // Hair styles (0-5 different styles) const hairStyle = getRandomNumberFromSeed(seed + 'hairstyle', 0, 5); - + // Eye style (0 = dots, 1 = curved lines) const eyeStyle = getRandomNumberFromSeed(seed + 'eyestyle', 0, 1); - + // Accessories const hasGlasses = getRandomNumberFromSeed(seed + 'glasses', 0, 3) === 0; - + return { skinColor, hairColor, hairStyle, eyeStyle, hasGlasses }; }, [seed]); - + const { skinColor, hairColor, hairStyle, eyeStyle, hasGlasses } = avatarData; - + return ( {/* Face - more oval shape */} - - + + {/* Hair */} {hairStyle === 0 && ( // Short neat hair - + )} {hairStyle === 1 && ( // Bob cut @@ -112,7 +134,10 @@ export function AvatarGenerator({ seed, size = 100, isDarkMode = false }: Avatar {hairStyle === 4 && ( // Pigtails <> - + @@ -126,7 +151,7 @@ export function AvatarGenerator({ seed, size = 100, isDarkMode = false }: Avatar fill={hairColor} /> )} - + {/* Eyes - cute dots or curved lines */} {eyeStyle === 0 ? ( // Dot eyes @@ -137,21 +162,39 @@ export function AvatarGenerator({ seed, size = 100, isDarkMode = false }: Avatar ) : ( // Happy curved line eyes <> - - + + )} - + {/* Simple nose */} - + {/* Cute smile */} - - + + {/* Cheeks (blush) */} - + {/* Glasses - rounder and cuter */} {hasGlasses && ( @@ -164,4 +207,4 @@ export function AvatarGenerator({ seed, size = 100, isDarkMode = false }: Avatar )} ); -} \ No newline at end of file +} diff --git a/src/components/BackgroundPattern.tsx b/apps/v1/client/src/components/BackgroundPattern.tsx similarity index 96% rename from src/components/BackgroundPattern.tsx rename to apps/v1/client/src/components/BackgroundPattern.tsx index 65229f01..8253e114 100644 --- a/src/components/BackgroundPattern.tsx +++ b/apps/v1/client/src/components/BackgroundPattern.tsx @@ -2,12 +2,15 @@ import { useTheme } from '../contexts/ThemeContextV2'; export function BackgroundPattern() { const { isDarkMode } = useTheme(); - + return ( -
+
{/* Gradient base */}
- + {/* Animated gradient orbs - more, bigger, more transparent */}
- + {/* Subtle grid pattern */} -
- + {/* Noise texture */} -
); -} \ No newline at end of file +} diff --git a/src/components/BackgroundPatternOptimized.tsx b/apps/v1/client/src/components/BackgroundPatternOptimized.tsx similarity index 68% rename from src/components/BackgroundPatternOptimized.tsx rename to apps/v1/client/src/components/BackgroundPatternOptimized.tsx index 6afdf743..e2d1cbc5 100644 --- a/src/components/BackgroundPatternOptimized.tsx +++ b/apps/v1/client/src/components/BackgroundPatternOptimized.tsx @@ -2,73 +2,83 @@ import { useTheme } from '../contexts/ThemeContextV2'; export function BackgroundPattern() { const { isDarkMode } = useTheme(); - + return (
{/* Static gradient base - no performance cost */}
- + {/* Animated gradient circles - blues with orange/pink accents */}
{/* Blue circle - top left */}
- + {/* Vibrant orange circle - bottom right */}
- + {/* Sky blue circle - center */}
- + {/* Vibrant pink circle - top right */}
- + {/* Deep blue circle - bottom left */}
- + {/* Very subtle noise texture */} -
); -} \ No newline at end of file +} diff --git a/src/components/BackgroundPatternStatic.tsx b/apps/v1/client/src/components/BackgroundPatternStatic.tsx similarity index 93% rename from src/components/BackgroundPatternStatic.tsx rename to apps/v1/client/src/components/BackgroundPatternStatic.tsx index 38c31f56..8c1278b7 100644 --- a/src/components/BackgroundPatternStatic.tsx +++ b/apps/v1/client/src/components/BackgroundPatternStatic.tsx @@ -2,14 +2,14 @@ import { useTheme } from '../contexts/ThemeContextV2'; export function BackgroundPattern() { const { isDarkMode } = useTheme(); - + return (
{/* Static gradient with mesh */} -
- + {/* Grid pattern for texture */} -
); -} \ No newline at end of file +} diff --git a/src/components/ConfirmDialog.tsx b/apps/v1/client/src/components/ConfirmDialog.tsx similarity index 79% rename from src/components/ConfirmDialog.tsx rename to apps/v1/client/src/components/ConfirmDialog.tsx index 8d6f3642..d6893872 100644 --- a/src/components/ConfirmDialog.tsx +++ b/apps/v1/client/src/components/ConfirmDialog.tsx @@ -20,7 +20,7 @@ export function ConfirmDialog({ cancelText = 'Cancel', onConfirm, onCancel, - variant = 'danger' + variant = 'danger', }: ConfirmDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -38,35 +38,32 @@ export function ConfirmDialog({ return (
{/* Backdrop */} -
- + {/* Modal */}
-
+ `} + >
-

- {title} -

+

{title}

-

- {message} -

+

{message}

-
-
); -} \ No newline at end of file +} diff --git a/src/components/DropdownTransition.tsx b/apps/v1/client/src/components/DropdownTransition.tsx similarity index 81% rename from src/components/DropdownTransition.tsx rename to apps/v1/client/src/components/DropdownTransition.tsx index 3b4fc85f..ba436ca9 100644 --- a/src/components/DropdownTransition.tsx +++ b/apps/v1/client/src/components/DropdownTransition.tsx @@ -9,17 +9,17 @@ interface DropdownTransitionProps { animationEnabled?: boolean; } -export function DropdownTransition({ - children, +export function DropdownTransition({ + children, isOpen, className = '', - animationEnabled = true + animationEnabled = true, }: DropdownTransitionProps) { const { animationsEnabled } = useTheme(); const [shouldRender, setShouldRender] = useState(false); const [isAnimating, setIsAnimating] = useState(false); const timeoutRef = useRef | undefined>(undefined); - + const shouldAnimate = animationEnabled && animationsEnabled; useEffect(() => { @@ -56,25 +56,24 @@ export function DropdownTransition({ if (!shouldRender) return null; - const animationStyles = shouldAnimate ? { - opacity: isAnimating ? 1 : 0, - transform: isAnimating ? 'translateY(0)' : 'translateY(-8px)', - transition: 'opacity 150ms ease-out, transform 150ms ease-out', - } : {}; + const animationStyles = shouldAnimate + ? { + opacity: isAnimating ? 1 : 0, + transform: isAnimating ? 'translateY(0)' : 'translateY(-8px)', + transition: 'opacity 150ms ease-out, transform 150ms ease-out', + } + : {}; // Merge animation styles with any height-related styles from className const finalStyles = { ...animationStyles, // Preserve height properties if className includes flex or height classes - ...(className.includes('flex-1') || className.includes('h-full') ? { height: '100%' } : {}) + ...(className.includes('flex-1') || className.includes('h-full') ? { height: '100%' } : {}), }; return ( -
+
{children}
); -} \ No newline at end of file +} diff --git a/src/components/FeedbackDialog.tsx b/apps/v1/client/src/components/FeedbackDialog.tsx similarity index 81% rename from src/components/FeedbackDialog.tsx rename to apps/v1/client/src/components/FeedbackDialog.tsx index 09cbcf62..8651a37a 100644 --- a/src/components/FeedbackDialog.tsx +++ b/apps/v1/client/src/components/FeedbackDialog.tsx @@ -19,17 +19,17 @@ export function FeedbackDialog({ onClose, onSubmit, isSubmitting = false, - error = null + error = null, }: FeedbackDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const [feedback, setFeedback] = useState(''); const [validationError, setValidationError] = useState(null); const textAreaRef = useRef(null); - + // Setup draggable functionality const { dragRef, handleRef, style: dragStyle } = useDraggable(); - + // Focus the textarea when dialog opens useEffect(() => { if (isOpen && textAreaRef.current) { @@ -57,7 +57,7 @@ export function FeedbackDialog({ let actualBehavior = ''; let expectedBehavior = ''; let isExpectedSection = false; - + for (const line of lines) { const lowerLine = line.toLowerCase(); if (lowerLine.includes('expected:') || lowerLine.includes('what i expected:')) { @@ -78,7 +78,10 @@ export function FeedbackDialog({ } try { - await onSubmit(expectedBehavior.trim() || 'Not specified', actualBehavior.trim() || feedback.trim()); + await onSubmit( + expectedBehavior.trim() || 'Not specified', + actualBehavior.trim() || feedback.trim() + ); // Clear form on success setFeedback(''); } catch (err) { @@ -96,23 +99,21 @@ export function FeedbackDialog({ const dialogContent = (
{/* Backdrop */} -
- + {/* Modal */} -
-
+
-
+
@@ -144,9 +145,19 @@ export function FeedbackDialog({ />
-
- - +
+ + A screenshot will be included automatically
@@ -159,12 +170,10 @@ export function FeedbackDialog({
-
-
@@ -88,4 +93,4 @@ export function FeedbackSuccessDialog({ ); return createPortal(dialogContent, document.body); -} \ No newline at end of file +} diff --git a/src/components/FolderBrowserDialog.tsx b/apps/v1/client/src/components/FolderBrowserDialog.tsx similarity index 54% rename from src/components/FolderBrowserDialog.tsx rename to apps/v1/client/src/components/FolderBrowserDialog.tsx index 8469f636..e71cf484 100644 --- a/src/components/FolderBrowserDialog.tsx +++ b/apps/v1/client/src/components/FolderBrowserDialog.tsx @@ -50,7 +50,7 @@ export function FolderBrowserDialog({ isOpen, onSelect, onCancel }: FolderBrowse const response = await fetch('http://localhost:3000/api/browse/home'); if (response.ok) { const data = await response.json(); - loadDirectory(data.path); + loadDirectory(data.path); } else { setError('Failed to get home directory'); setIsLoading(false); @@ -64,25 +64,23 @@ export function FolderBrowserDialog({ isOpen, onSelect, onCancel }: FolderBrowse const loadDirectory = async (path: string, isGoingUp: boolean = false) => { if (!isOpen) return; - + // Only show loading on initial load if (!currentPath) { setIsLoading(true); } setError(null); setNavigationDirection(isGoingUp ? 'backward' : 'forward'); - - + try { const response = await fetch('http://localhost:3000/api/browse/list', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ path }) + body: JSON.stringify({ path }), }); - if (response.ok) { const data: BrowseResponse = await response.json(); setCurrentPath(data.currentPath); @@ -141,10 +139,10 @@ export function FolderBrowserDialog({ isOpen, onSelect, onCancel }: FolderBrowse headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + body: JSON.stringify({ parentPath: currentPath, - folderName: trimmedName - }) + folderName: trimmedName, + }), }); if (response.ok) { @@ -174,145 +172,171 @@ export function FolderBrowserDialog({ isOpen, onSelect, onCancel }: FolderBrowse } }; - if (!isOpen) return null; const renderContent = () => ( -
- {/* Header */} -
-

- Select folder -

-
+ `} + > + {/* Header */} +
+

Select folder

+
- {/* Current path */} -
-
- Current path: - - {currentPath || 'Loading...'} - -
-
+ {/* Current path */} +
+
+ Current path: + + {currentPath || 'Loading...'} + +
+
- {/* Browser */} -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {error} -
- ) : ( - -
- {/* New folder input */} - {isCreatingFolder && ( -
- - - - setNewFolderName(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={() => { - if (!newFolderName.trim()) { - handleCancelCreate(); - } - }} - placeholder="Folder name" - autoFocus - className={` + {/* Browser */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( + +
+ {/* New folder input */} + {isCreatingFolder && ( +
+ + + + setNewFolderName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newFolderName.trim()) { + handleCancelCreate(); + } + }} + placeholder="Folder name" + autoFocus + className={` flex-1 px-2 py-1 text-sm ${styles.contentBg} ${styles.textColor} border-0 outline-none focus:ring-0 `} - /> -
- )} + /> +
+ )} - {/* Go up button */} - {parentPath && parentPath !== currentPath && ( - - )} + > + + + + .. + + )} - {/* Directory items */} - {items.map((item) => ( - - ))} - -
- - )} -
- - {/* Footer */} -
- -
- - + > + + + + {item.name} + + ))}
-
+ + )} +
+ + {/* Footer */} +
+ +
+ +
+
+
); // Check if we're being rendered inside another modal container @@ -326,11 +350,9 @@ export function FolderBrowserDialog({ isOpen, onSelect, onCancel }: FolderBrowse
{/* Backdrop */}
- + {/* Modal */} -
- {renderContent()} -
+
{renderContent()}
); -} \ No newline at end of file +} diff --git a/src/components/ImportingWorkspaceDialog.tsx b/apps/v1/client/src/components/ImportingWorkspaceDialog.tsx similarity index 53% rename from src/components/ImportingWorkspaceDialog.tsx rename to apps/v1/client/src/components/ImportingWorkspaceDialog.tsx index a65a84a2..ef3f1d31 100644 --- a/src/components/ImportingWorkspaceDialog.tsx +++ b/apps/v1/client/src/components/ImportingWorkspaceDialog.tsx @@ -25,10 +25,10 @@ interface ImportingWorkspaceDialogProps { onComplete: () => void; } -export function ImportingWorkspaceDialog({ - isOpen, +export function ImportingWorkspaceDialog({ + isOpen, workspacePath, - onComplete + onComplete, }: ImportingWorkspaceDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -46,7 +46,7 @@ export function ImportingWorkspaceDialog({ { id: '3', description: 'Extracting repository information', status: 'pending' }, { id: '4', description: 'Analyzing project structure', status: 'pending' }, { id: '5', description: 'Importing work items', status: 'pending' }, - { id: '6', description: 'Finalizing workspace setup', status: 'pending' } + { id: '6', description: 'Finalizing workspace setup', status: 'pending' }, ]; setTasks(initialTasks); setCurrentTaskIndex(0); @@ -57,83 +57,96 @@ export function ImportingWorkspaceDialog({ if (currentTaskIndex >= 0 && currentTaskIndex < tasks.length) { // Start processing current task const timer = setTimeout(() => { - setTasks(prev => prev.map((task, index) => { - if (index === currentTaskIndex) { - return { ...task, status: 'processing' }; - } - return task; - })); + setTasks((prev) => + prev.map((task, index) => { + if (index === currentTaskIndex) { + return { ...task, status: 'processing' }; + } + return task; + }) + ); // Simulate task completion - setTimeout(async () => { - let fetchedProjects: ImportedProject[] = []; - - // For the reading project configurations task, actually fetch the data - if (currentTaskIndex === 1) { - try { - console.log('Fetching workspace data for:', workspacePath); - const response = await fetch('http://localhost:3000/api/workspace/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspacePath }) - }); - - if (response.ok) { - const data = await response.json(); - console.log('Workspace data received:', data); - fetchedProjects = data.projects.map((proj: any) => ({ - name: proj.name, - description: proj.readme ? extractDescription(proj.readme) : 'No description available', - purpose: proj.purpose, - repositories: proj.repositories || [], - workItemCount: proj.plans ? - Object.values(proj.plans).flat().length : 0 - })); - console.log('Imported projects:', fetchedProjects); + setTimeout( + async () => { + let fetchedProjects: ImportedProject[] = []; + + // For the reading project configurations task, actually fetch the data + if (currentTaskIndex === 1) { + try { + console.log('Fetching workspace data for:', workspacePath); + const response = await fetch('http://localhost:3000/api/workspace/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspacePath }), + }); + + if (response.ok) { + const data = await response.json(); + console.log('Workspace data received:', data); + fetchedProjects = data.projects.map((proj: any) => ({ + name: proj.name, + description: proj.readme + ? extractDescription(proj.readme) + : 'No description available', + purpose: proj.purpose, + repositories: proj.repositories || [], + workItemCount: proj.plans ? Object.values(proj.plans).flat().length : 0, + })); + console.log('Imported projects:', fetchedProjects); + setProjects(fetchedProjects); + } else { + console.error('Failed to read workspace:', response.status, response.statusText); + } + } catch (error) { + console.error('Error reading workspace:', error); + // If fetch fails, use mock data for testing + console.log('Using mock data due to fetch error'); + fetchedProjects = [ + { + name: 'project-management-ux-exploration', + description: + 'An exploratory web application to prototype and iterate on UX ideas', + purpose: 'Prototype and iterate on UX ideas for managing projects', + repositories: [ + { + url: 'local://development', + type: 'github' as const, + visibility: 'private' as const, + isPrimary: true, + }, + ], + workItemCount: 0, + }, + ]; setProjects(fetchedProjects); - } else { - console.error('Failed to read workspace:', response.status, response.statusText); } - } catch (error) { - console.error('Error reading workspace:', error); - // If fetch fails, use mock data for testing - console.log('Using mock data due to fetch error'); - fetchedProjects = [{ - name: 'project-management-ux-exploration', - description: 'An exploratory web application to prototype and iterate on UX ideas', - purpose: 'Prototype and iterate on UX ideas for managing projects', - repositories: [{ - url: 'local://development', - type: 'github' as const, - visibility: 'private' as const, - isPrimary: true - }], - workItemCount: 0 - }]; - setProjects(fetchedProjects); } - } - // Use the fetched projects or current state for task details - const projectsToUse = currentTaskIndex === 1 ? fetchedProjects : projects; - - setTasks(prev => prev.map((task, index) => { - if (index === currentTaskIndex) { - return { - ...task, - status: 'completed', - details: getTaskDetails(index, projectsToUse) - }; - } - return task; - })); + // Use the fetched projects or current state for task details + const projectsToUse = currentTaskIndex === 1 ? fetchedProjects : projects; + + setTasks((prev) => + prev.map((task, index) => { + if (index === currentTaskIndex) { + return { + ...task, + status: 'completed', + details: getTaskDetails(index, projectsToUse), + }; + } + return task; + }) + ); - if (currentTaskIndex < tasks.length - 1) { - setCurrentTaskIndex(currentTaskIndex + 1); - } else { - setIsComplete(true); - } - }, 200 + Math.random() * 300); // Variable completion time (200-500ms) + if (currentTaskIndex < tasks.length - 1) { + setCurrentTaskIndex(currentTaskIndex + 1); + } else { + setIsComplete(true); + } + }, + 200 + Math.random() * 300 + ); // Variable completion time (200-500ms) }, 50); // Start processing faster return () => clearTimeout(timer); @@ -153,19 +166,24 @@ export function ImportingWorkspaceDialog({ const getTaskDetails = (taskIndex: number, projectList: ImportedProject[]): string => { switch (taskIndex) { - case 0: return 'Found projects folder'; - case 1: return `Found ${projectList.length} project${projectList.length !== 1 ? 's' : ''}`; + case 0: + return 'Found projects folder'; + case 1: + return `Found ${projectList.length} project${projectList.length !== 1 ? 's' : ''}`; case 2: { const totalRepos = projectList.reduce((sum, p) => sum + p.repositories.length, 0); return `Discovered ${totalRepos} repositor${totalRepos !== 1 ? 'ies' : 'y'}`; } - case 3: return 'Structure analyzed successfully'; + case 3: + return 'Structure analyzed successfully'; case 4: { const totalWorkItems = projectList.reduce((sum, p) => sum + p.workItemCount, 0); return `Imported ${totalWorkItems} work item${totalWorkItems !== 1 ? 's' : ''}`; } - case 5: return 'Workspace ready'; - default: return ''; + case 5: + return 'Workspace ready'; + default: + return ''; } }; @@ -173,18 +191,31 @@ export function ImportingWorkspaceDialog({ switch (status) { case 'completed': return ( - + ); case 'processing': - return ( - - ); + return ; case 'error': return ( - - + + ); default: @@ -198,19 +229,19 @@ export function ImportingWorkspaceDialog({ {/* Backdrop */}
- + {/* Modal */}
-
+ `} + > {/* Header */}
-

- Importing workspace -

+

Importing workspace

Discovering and importing existing projects from {workspacePath}

@@ -222,16 +253,19 @@ export function ImportingWorkspaceDialog({
{tasks.map((task) => (
-
- {getStatusIcon(task.status)} -
+
{getStatusIcon(task.status)}
-
+
{task.description}
-
+
{task.details || '\u00A0' /* non-breaking space to maintain height */}
@@ -242,21 +276,34 @@ export function ImportingWorkspaceDialog({ {/* Projects summary with fixed height */}
{projects.length > 0 ? ( -
+

Discovered projects

{projects.map((project, index) => ( -
+
{project.name}
-
{project.description}
+
+ {project.description} +
{project.repositories.length > 0 && ( - {project.repositories.length} repositor{project.repositories.length !== 1 ? 'ies' : 'y'} + + {project.repositories.length} repositor + {project.repositories.length !== 1 ? 'ies' : 'y'} + )} {project.workItemCount > 0 && ( - {project.workItemCount} work item{project.workItemCount !== 1 ? 's' : ''} + + {project.workItemCount} work item + {project.workItemCount !== 1 ? 's' : ''} + )}
@@ -264,7 +311,9 @@ export function ImportingWorkspaceDialog({
) : ( -
+
Scanning for projects...
)} @@ -290,4 +339,4 @@ export function ImportingWorkspaceDialog({
); -} \ No newline at end of file +} diff --git a/src/components/Layout.tsx b/apps/v1/client/src/components/Layout.tsx similarity index 85% rename from src/components/Layout.tsx rename to apps/v1/client/src/components/Layout.tsx index 52013526..7d18ad47 100644 --- a/src/components/Layout.tsx +++ b/apps/v1/client/src/components/Layout.tsx @@ -2,9 +2,9 @@ import { Link, Outlet, useLocation } from 'react-router-dom'; export function Layout() { const location = useLocation(); - + const isActive = (path: string) => location.pathname === path; - + return (
- +
); -} \ No newline at end of file +} diff --git a/src/components/Portal.tsx b/apps/v1/client/src/components/Portal.tsx similarity index 98% rename from src/components/Portal.tsx rename to apps/v1/client/src/components/Portal.tsx index c68cc9f0..423d0f3b 100644 --- a/src/components/Portal.tsx +++ b/apps/v1/client/src/components/Portal.tsx @@ -12,15 +12,15 @@ export function Portal({ children, containerId = 'portal-root' }: PortalProps) { useEffect(() => { // Find or create the container element let container = document.getElementById(containerId); - + if (!container) { container = document.createElement('div'); container.id = containerId; document.body.appendChild(container); } - + containerRef.current = container; - + // Cleanup: only remove if we created it and it's empty return () => { if (container && container.childNodes.length === 0) { @@ -34,4 +34,4 @@ export function Portal({ children, containerId = 'portal-root' }: PortalProps) { } return createPortal(children, containerRef.current); -} \ No newline at end of file +} diff --git a/src/components/ProjectDeleteDialog.tsx b/apps/v1/client/src/components/ProjectDeleteDialog.tsx similarity index 81% rename from src/components/ProjectDeleteDialog.tsx rename to apps/v1/client/src/components/ProjectDeleteDialog.tsx index 25208830..d43deb00 100644 --- a/src/components/ProjectDeleteDialog.tsx +++ b/apps/v1/client/src/components/ProjectDeleteDialog.tsx @@ -12,7 +12,12 @@ interface ProjectDeleteDialogProps { onCancel: () => void; } -export function ProjectDeleteDialog({ isOpen, project, onConfirm, onCancel }: ProjectDeleteDialogProps) { +export function ProjectDeleteDialog({ + isOpen, + project, + onConfirm, + onCancel, +}: ProjectDeleteDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const [isDeleting, setIsDeleting] = useState(false); @@ -34,22 +39,24 @@ export function ProjectDeleteDialog({ isOpen, project, onConfirm, onCancel }: Pr
- + -
+ `} + >

Confirm removing project

- +

Do you want to remove the project "{project.name}"?

- +
- -
@@ -78,4 +77,4 @@ export function ProjectDeleteDialog({ isOpen, project, onConfirm, onCancel }: Pr
); -} \ No newline at end of file +} diff --git a/src/components/SettingsMenu.tsx b/apps/v1/client/src/components/SettingsMenu.tsx similarity index 52% rename from src/components/SettingsMenu.tsx rename to apps/v1/client/src/components/SettingsMenu.tsx index 9e94cfb3..5b923fad 100644 --- a/src/components/SettingsMenu.tsx +++ b/apps/v1/client/src/components/SettingsMenu.tsx @@ -7,7 +7,13 @@ import { DropdownTransition } from './DropdownTransition'; export function SettingsMenu() { const [isOpen, setIsOpen] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false); - const { currentStyles, toggleDarkMode, isDarkMode, toggleBackgroundEffect, backgroundEffectEnabled } = useTheme(); + const { + currentStyles, + toggleDarkMode, + isDarkMode, + toggleBackgroundEffect, + backgroundEffectEnabled, + } = useTheme(); const { showToast } = useToast(); const styles = currentStyles; const menuRef = useRef(null); @@ -29,87 +35,127 @@ export function SettingsMenu() { label: 'Settings', icon: ( - - + + ), onClick: () => { setShowSettingsModal(true); setIsOpen(false); - } + }, }, { label: 'divider', - isDivider: true + isDivider: true, }, { label: isDarkMode ? 'Light mode' : 'Dark mode', icon: isDarkMode ? ( - + ) : ( - + ), onClick: () => { toggleDarkMode(); - } + }, }, { label: backgroundEffectEnabled ? 'Disable background' : 'Enable background', icon: ( - + ), onClick: () => { toggleBackgroundEffect(); - } + }, }, { label: 'divider', - isDivider: true + isDivider: true, }, { label: 'Keyboard shortcuts', icon: ( - + ), onClick: () => { // TODO: Show keyboard shortcuts modal showToast('Keyboard shortcuts coming soon!', 'info'); setIsOpen(false); - } + }, }, { label: 'Help & documentation', icon: ( - + ), onClick: () => { window.open('https://github.com/dzearing/project-mgmt-ux', '_blank'); setIsOpen(false); - } + }, }, { label: 'About Claude Flow', icon: ( - + ), onClick: () => { // TODO: Show about modal showToast('Claude Flow v1.0.0 - AI-powered project management', 'info'); setIsOpen(false); - } - } + }, + }, ]; return ( @@ -124,17 +170,37 @@ export function SettingsMenu() { focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 `} > - - - + + + - -
+
+ `} + >
{menuItems.map((item, index) => { if (item.isDivider) { @@ -160,10 +226,7 @@ export function SettingsMenu() {
- setShowSettingsModal(false)} - /> + setShowSettingsModal(false)} />
); -} \ No newline at end of file +} diff --git a/src/components/SettingsModal.tsx b/apps/v1/client/src/components/SettingsModal.tsx similarity index 80% rename from src/components/SettingsModal.tsx rename to apps/v1/client/src/components/SettingsModal.tsx index 0373f279..2186e9eb 100644 --- a/src/components/SettingsModal.tsx +++ b/apps/v1/client/src/components/SettingsModal.tsx @@ -13,7 +13,15 @@ interface SettingsModalProps { } export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { - const { currentStyles, isDarkMode, toggleDarkMode, backgroundEffectEnabled, toggleBackgroundEffect, animationsEnabled, toggleAnimations } = useTheme(); + const { + currentStyles, + isDarkMode, + toggleDarkMode, + backgroundEffectEnabled, + toggleBackgroundEffect, + animationsEnabled, + toggleAnimations, + } = useTheme(); const { workspace, setWorkspacePath, reloadWorkspace } = useWorkspace(); const { showToast } = useToast(); const styles = currentStyles; @@ -43,7 +51,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } }, [isOpen, workspace.config?.path]); - const handleMockModeChange = (newValue: boolean) => { setMockMode(newValue); localStorage.setItem('mockMode', JSON.stringify(newValue)); @@ -53,29 +60,33 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { return ( {/* Backdrop */} -
- + {/* Modal */}
-
+ `} + > {/* Header */} -
+

Settings

- + - +
@@ -85,16 +96,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {[ { id: 'workspace', label: 'Workspace' }, { id: 'appearance', label: 'Appearance' }, - { id: 'features', label: 'Features' } - ].map(tab => ( + { id: 'features', label: 'Features' }, + ].map((tab) => (
- +

- {workspace.config ? `Current: ${workspace.config.path}` : 'No workspace configured'} + {workspace.config + ? `Current: ${workspace.config.path}` + : 'No workspace configured'}

@@ -182,11 +208,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { - + `} + > @@ -217,8 +247,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {activeTab === 'appearance' && (
-

Appearance settings

- +

+ Appearance settings +

+
Font size - + `} + > @@ -287,8 +323,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {activeTab === 'features' && (
-

Feature settings

- +

+ Feature settings +

+
-
+

- {mockMode - ? "Mock mode is enabled. AI features will use simulated responses." - : "Mock mode is disabled. AI features will use Claude API."} + {mockMode + ? 'Mock mode is enabled. AI features will use simulated responses.' + : 'Mock mode is disabled. AI features will use Claude API.'}

-

AI assistant features

- +

+ AI assistant features +

+
- + {}} label="Persona suggestions" className="justify-between" /> - + {}} @@ -333,8 +375,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
-

Notifications

- +

+ Notifications +

+
- + {}} label="Email notifications" className="justify-between" /> - + {}} @@ -365,7 +409,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{/* Footer */} -
+
@@ -385,4 +431,4 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
); -} \ No newline at end of file +} diff --git a/apps/v1/client/src/components/StockPhotoAvatar.tsx b/apps/v1/client/src/components/StockPhotoAvatar.tsx new file mode 100644 index 00000000..bd0d492e --- /dev/null +++ b/apps/v1/client/src/components/StockPhotoAvatar.tsx @@ -0,0 +1,355 @@ +import { useMemo } from 'react'; +import { CrossFade } from './ui/CrossFade'; + +interface StockPhotoAvatarProps { + seed: string; + size?: number; + gender?: 'male' | 'female' | 'any'; +} + +const maleNames = [ + 'James', + 'John', + 'Robert', + 'Michael', + 'William', + 'David', + 'Richard', + 'Joseph', + 'Thomas', + 'Daniel', + 'Matthew', + 'Andrew', + 'Paul', + 'Joshua', + 'Kenneth', + 'Kevin', + 'Brian', + 'George', + 'Steven', + 'Edward', + 'Ronald', + 'Timothy', + 'Jason', + 'Jeffrey', + 'Ryan', + 'Jacob', + 'Gary', + 'Nicholas', + 'Eric', + 'Jonathan', + 'Stephen', + 'Larry', + 'Justin', + 'Scott', + 'Brandon', + 'Benjamin', + 'Samuel', + 'Frank', + 'Gregory', + 'Raymond', + 'Alexander', + 'Patrick', + 'Jack', + 'Dennis', + 'Jerry', + 'Tyler', + 'Aaron', + 'Jose', + 'Nathan', + 'Henry', + 'Zachary', + 'Douglas', + 'Peter', + 'Adam', + 'Kyle', + 'Noah', + 'Charles', + 'Christopher', + 'Anthony', + 'Mark', + 'Donald', + 'Kenneth', + 'Steven', + 'Albert', + 'Willie', + 'Elijah', + 'Wayne', + 'Jordan', + 'Dylan', + 'Arthur', + 'Bryan', + 'Carl', + 'Christian', + 'Eugene', + 'Russell', + 'Louis', + 'Philip', + 'Johnny', + 'Austin', + 'Gabriel', + 'Logan', + 'Albert', + 'Juan', + 'Vincent', + 'Ralph', + 'Roy', + 'Eugene', + 'Randy', + 'Mason', + 'Russell', + 'Louis', + 'Philip', + 'Johnny', + 'Harry', + 'Jesse', + 'Craig', + 'Alan', + 'Ralph', + 'Willie', + 'Albert', + 'Wayne', + 'Ethan', + 'Jeremy', + 'Keith', + 'Terry', + 'Sean', + 'Gerald', + 'Carl', + 'Harold', + 'Jordan', + 'Jesse', + 'Bryan', + 'Lawrence', + 'Arthur', + 'Gabriel', + 'Bruce', + 'Logan', + 'Juan', + 'Elijah', + 'Willie', + 'Albert', + 'Mason', + 'Vincent', + 'Ralph', + 'Roy', + 'Eugene', + 'Russell', + 'Louis', +]; + +const femaleNames = [ + 'Mary', + 'Patricia', + 'Jennifer', + 'Linda', + 'Elizabeth', + 'Barbara', + 'Susan', + 'Jessica', + 'Sarah', + 'Karen', + 'Nancy', + 'Lisa', + 'Betty', + 'Dorothy', + 'Sandra', + 'Ashley', + 'Kimberly', + 'Emily', + 'Donna', + 'Michelle', + 'Carol', + 'Amanda', + 'Melissa', + 'Deborah', + 'Stephanie', + 'Rebecca', + 'Sharon', + 'Laura', + 'Cynthia', + 'Kathleen', + 'Amy', + 'Angela', + 'Shirley', + 'Anna', + 'Brenda', + 'Emma', + 'Helen', + 'Pamela', + 'Nicole', + 'Samantha', + 'Katherine', + 'Christine', + 'Debra', + 'Rachel', + 'Janet', + 'Catherine', + 'Maria', + 'Heather', + 'Diane', + 'Ruth', + 'Julie', + 'Olivia', + 'Joyce', + 'Virginia', + 'Victoria', + 'Kelly', + 'Lauren', + 'Christina', + 'Joan', + 'Evelyn', + 'Judith', + 'Megan', + 'Andrea', + 'Cheryl', + 'Hannah', + 'Martha', + 'Madison', + 'Teresa', + 'Gloria', + 'Sara', + 'Janice', + 'Marie', + 'Julia', + 'Grace', + 'Judy', + 'Theresa', + 'Rose', + 'Beverly', + 'Denise', + 'Marilyn', + 'Amber', + 'Danielle', + 'Abigail', + 'Brittany', + 'Kathryn', + 'Diana', + 'Lori', + 'Tiffany', + 'Alexis', + 'Kayla', + 'Frances', + 'Ann', + 'Alice', + 'Jean', + 'Doris', + 'Jacqueline', + 'Natalie', + 'Charlotte', + 'Marie', + 'Janet', + 'Catherine', + 'Frances', + 'Christina', + 'Samantha', + 'Deborah', + 'Janet', + 'Carolyn', + 'Rachel', + 'Martha', + 'Maria', + 'Heather', + 'Diane', + 'Sophia', + 'Isabella', + 'Mia', + 'Ava', + 'Chloe', + 'Zoey', + 'Lily', + 'Madison', + 'Ella', + 'Avery', + 'Sofia', + 'Scarlett', + 'Grace', + 'Victoria', + 'Aria', + 'Luna', +]; + +// Simple hash function to get consistent random values from seed +export function hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +export function getRandomName(seed: string, gender?: 'male' | 'female' | 'any'): string { + // If gender is explicitly provided and not 'any', use it + const actualGender = gender && gender !== 'any' ? gender : getGenderFromSeed(seed); + + const nameList = actualGender === 'male' ? maleNames : femaleNames; + const index = hashCode(seed) % nameList.length; + + const name = nameList[index]; + console.log('getRandomName:', { + seed, + gender, + actualGender, + index, + name, + isMale: actualGender === 'male', + }); + + return name; +} + +export function getGenderFromSeed(seed: string): 'male' | 'female' { + return hashCode(seed) % 2 === 0 ? 'female' : 'male'; +} + +export function StockPhotoAvatar({ seed, size = 80, gender = 'any' }: StockPhotoAvatarProps) { + const actualGender = useMemo(() => { + const g = gender === 'any' ? getGenderFromSeed(seed) : gender; + console.log('StockPhotoAvatar gender:', { seed, gender, actualGender: g }); + return g; + }, [seed, gender]); + + const avatarUrl = useMemo(() => { + // Using randomuser.me API which provides real photos of people + // The seed ensures we get the same photo for the same input + const genderFolder = actualGender === 'female' ? 'women' : 'men'; + const photoIndex = hashCode(seed) % 100; + + console.log('StockPhotoAvatar URL:', { seed, actualGender, genderFolder, photoIndex }); + + return `https://randomuser.me/api/portraits/${genderFolder}/${photoIndex}.jpg`; + }, [seed, actualGender]); + + // Determine border color based on gender + const borderColor = actualGender === 'female' ? '#EC4899' : '#3B82F6'; // pink-500 : blue-500 + + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/ThemeSwitcher.tsx b/apps/v1/client/src/components/ThemeSwitcher.tsx similarity index 88% rename from src/components/ThemeSwitcher.tsx rename to apps/v1/client/src/components/ThemeSwitcher.tsx index 38e67e52..fda5309e 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/apps/v1/client/src/components/ThemeSwitcher.tsx @@ -4,8 +4,8 @@ import { useEffect } from 'react'; export function ThemeSwitcher() { const { currentTheme, setTheme, nextTheme, previousTheme } = useTheme(); - const currentIndex = themes.findIndex(t => t.id === currentTheme.id); - + const currentIndex = themes.findIndex((t) => t.id === currentTheme.id); + useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { @@ -18,24 +18,26 @@ export function ThemeSwitcher() { } } }; - + window.addEventListener('keydown', handleKeyPress); return () => window.removeEventListener('keydown', handleKeyPress); }, [nextTheme, previousTheme]); - + return (

Theme Switcher

- {currentIndex + 1} / {themes.length} + + {currentIndex + 1} / {themes.length} +
- +

{currentTheme.name}

{currentTheme.description}

- +
- +
{themes.map((theme, index) => ( ))}
- +
Use Ctrl/Cmd + Arrow keys or click to switch themes
); -} \ No newline at end of file +} diff --git a/apps/v1/client/src/components/ThemeSwitcherV2.tsx b/apps/v1/client/src/components/ThemeSwitcherV2.tsx new file mode 100644 index 00000000..da9ee5bc --- /dev/null +++ b/apps/v1/client/src/components/ThemeSwitcherV2.tsx @@ -0,0 +1,178 @@ +import { useTheme } from '../contexts/ThemeContextV2'; +import { themesV2 } from '../config/themesV2'; +import { useEffect, useState } from 'react'; + +export function ThemeSwitcherV2() { + const { + currentTheme, + isDarkMode, + setTheme, + nextTheme, + previousTheme, + toggleDarkMode, + backgroundEffectEnabled, + toggleBackgroundEffect, + } = useTheme(); + const currentIndex = themesV2.findIndex((t) => t.id === currentTheme.id); + const [isMinimized, setIsMinimized] = useState(() => { + const saved = localStorage.getItem('themeSwitcherMinimized'); + return saved === 'true'; + }); + + useEffect(() => { + localStorage.setItem('themeSwitcherMinimized', isMinimized.toString()); + }, [isMinimized]); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === 'd' || e.key === 'D') { + e.preventDefault(); + toggleDarkMode(); + } else if (e.key === 'b' || e.key === 'B') { + e.preventDefault(); + toggleBackgroundEffect(); + } + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [nextTheme, previousTheme, toggleDarkMode, toggleBackgroundEffect]); + + return ( +
+ {isMinimized ? ( + + ) : ( +
+
+

+ Theme Switcher +

+
+ + {currentIndex + 1} / {themesV2.length} + + +
+
+ + {/* Dark Mode Toggle */} +
+ Dark Mode + +
+ + {/* Background Effect Toggle */} +
+ Background Effect + +
+ +
+

{currentTheme.name}

+

+ {currentTheme.description} +

+
+ +
+ + +
+ +
+ {themesV2.map((theme, index) => ( + + ))} +
+ +
+
Use Ctrl/Cmd + D to toggle dark mode
+
Use Ctrl/Cmd + B to toggle background effect
+
+
+ )} +
+ ); +} diff --git a/src/components/ThemedLayout.tsx b/apps/v1/client/src/components/ThemedLayout.tsx similarity index 84% rename from src/components/ThemedLayout.tsx rename to apps/v1/client/src/components/ThemedLayout.tsx index 0f33bcd6..af6b1364 100644 --- a/src/components/ThemedLayout.tsx +++ b/apps/v1/client/src/components/ThemedLayout.tsx @@ -5,7 +5,7 @@ export function ThemedLayout() { const location = useLocation(); const { currentTheme } = useTheme(); const styles = currentTheme.styles; - + const navItems = [ { path: '/', label: 'Dashboard' }, { path: '/projects', label: 'Projects' }, @@ -13,19 +13,17 @@ export function ThemedLayout() { { path: '/personas', label: 'Personas' }, { path: '/jam-sessions', label: 'Jam Sessions' }, ]; - + const isActive = (path: string) => location.pathname === path; - + return (
{/* Sidebar */} - + {/* Main Content */}

- {navItems.find(item => isActive(item.path))?.label || 'Page'} + {navItems.find((item) => isActive(item.path))?.label || 'Page'}

-
- Theme: {currentTheme.name} -
+
Theme: {currentTheme.name}
- +
@@ -80,4 +77,4 @@ export function ThemedLayout() {
); -} \ No newline at end of file +} diff --git a/src/components/ThemedLayoutV2.tsx b/apps/v1/client/src/components/ThemedLayoutV2.tsx similarity index 80% rename from src/components/ThemedLayoutV2.tsx rename to apps/v1/client/src/components/ThemedLayoutV2.tsx index 4f06a58e..62f9a0d8 100644 --- a/src/components/ThemedLayoutV2.tsx +++ b/apps/v1/client/src/components/ThemedLayoutV2.tsx @@ -20,26 +20,25 @@ export function ThemedLayoutV2() { const styles = currentStyles; const direction = useNavigationDirection(); - // Extract projectId from various routes const getProjectIdFromPath = () => { // Match /projects/:projectId/workitems/new const projectMatch = location.pathname.match(/^\/projects\/([^\/]+)\/workitems\/new$/); if (projectMatch) return projectMatch[1]; - + // Match /work-items/:workItemId/edit const editMatch = location.pathname.match(/^\/work-items\/([^\/]+)\/edit$/); if (editMatch) { - const workItem = workItems.find(w => w.id === editMatch[1]); + const workItem = workItems.find((w) => w.id === editMatch[1]); return workItem?.projectId; } - + return null; }; - + const projectId = getProjectIdFromPath(); - const project = projectId ? projects.find(p => p.id === projectId) : null; - + const project = projectId ? projects.find((p) => p.id === projectId) : null; + const navItems = [ { path: '/', label: 'Dashboard' }, { path: '/projects', label: 'Projects' }, @@ -47,56 +46,60 @@ export function ThemedLayoutV2() { { path: '/agents', label: 'Agents' }, { path: '/jam-sessions', label: 'Jam sessions' }, ]; - + const isActive = (path: string) => location.pathname === path; - const currentLabel = navItems.find(item => item.path === location.pathname)?.label || null; - + const currentLabel = navItems.find((item) => item.path === location.pathname)?.label || null; + // Determine what to show in the header const getHeaderContent = () => { // If headerContent is set by a page component, use it if (headerContent !== null) { return headerContent; } - + // If headerTitle is set by a page component, use it if (headerTitle) { return headerTitle; } - + // Pages that should not show header text (they have their own headers) const pagesWithOwnHeaders = ['/work-items', '/agents', '/projects', '/jam-sessions', '/']; if (pagesWithOwnHeaders.includes(location.pathname)) { return null; } - + // Otherwise, use the default logic - if (project && (location.pathname.includes('/workitems/new') || location.pathname.includes('/work-items/') && location.pathname.includes('/edit'))) { + if ( + project && + (location.pathname.includes('/workitems/new') || + (location.pathname.includes('/work-items/') && location.pathname.includes('/edit'))) + ) { return project.name; } if (location.pathname === '/work-items/new') { return 'Create work item'; } - + // For routes like /agents/new, don't show anything if (!currentLabel) { return null; } - + return currentLabel; }; - + return ( -
+
{/* Background pattern when enabled */} {backgroundEffectEnabled && } {/* Sidebar */} - + {/* Main Content */}
-
+
{(() => { const content = getHeaderContent(); if (!content) return null; - + // Check if content is an array (breadcrumbs) if (Array.isArray(content)) { // Create a stable key from breadcrumb content - const breadcrumbKey = content.map(item => item.label).join(' > '); + const breadcrumbKey = content.map((item) => item.label).join(' > '); return ( - ); } - + // Otherwise it's a string return ( - -

- {content} -

+

{content}

); })()}
{workspace.config && ( -
- {workspace.config.path} -
+
{workspace.config.path}
)}
- +
); -} \ No newline at end of file +} diff --git a/src/components/ToastContainer.tsx b/apps/v1/client/src/components/ToastContainer.tsx similarity index 69% rename from src/components/ToastContainer.tsx rename to apps/v1/client/src/components/ToastContainer.tsx index 45a7e3e8..f0fbd3e9 100644 --- a/src/components/ToastContainer.tsx +++ b/apps/v1/client/src/components/ToastContainer.tsx @@ -19,25 +19,25 @@ export function ToastContainer() { // Initialize or clean up toast states when toasts change useEffect(() => { // Add new toasts to state - toasts.forEach(toast => { + toasts.forEach((toast) => { if (!toastStates[toast.id]) { - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toast.id]: { opacity: 1, - isHovered: false - } + isHovered: false, + }, })); // Start fade timer if duration is set if (toast.duration && toast.duration > 0) { const fadeTimeout = setTimeout(() => { - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toast.id]: { ...prev[toast.id], - opacity: 0 - } + opacity: 0, + }, })); // Remove toast after fade completes @@ -45,34 +45,34 @@ export function ToastContainer() { removeToast(toast.id); }, 5000); // 5 second fade duration - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toast.id]: { ...prev[toast.id], - removeTimeout - } + removeTimeout, + }, })); }, 5000); // Start fade after 5 seconds - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toast.id]: { ...prev[toast.id], - fadeTimeout - } + fadeTimeout, + }, })); } } }); // Clean up removed toasts - const toastIds = toasts.map(t => t.id); - Object.keys(toastStates).forEach(id => { + const toastIds = toasts.map((t) => t.id); + Object.keys(toastStates).forEach((id) => { if (!toastIds.includes(id)) { const state = toastStates[id]; if (state.fadeTimeout) clearTimeout(state.fadeTimeout); if (state.removeTimeout) clearTimeout(state.removeTimeout); - setToastStates(prev => { + setToastStates((prev) => { const newState = { ...prev }; delete newState[id]; return newState; @@ -90,12 +90,12 @@ export function ToastContainer() { if (state.removeTimeout) clearTimeout(state.removeTimeout); // Set opacity back to 1 and mark as hovered - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toastId]: { opacity: 1, - isHovered: true - } + isHovered: true, + }, })); }; @@ -103,44 +103,44 @@ export function ToastContainer() { const state = toastStates[toastId]; if (!state) return; - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toastId]: { ...prev[toastId], - isHovered: false - } + isHovered: false, + }, })); // Restart the countdown if duration is set if (duration && duration > 0) { const fadeTimeout = setTimeout(() => { - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toastId]: { ...prev[toastId], - opacity: 0 - } + opacity: 0, + }, })); const removeTimeout = setTimeout(() => { removeToast(toastId); }, 5000); // 5 second fade duration - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toastId]: { ...prev[toastId], - removeTimeout - } + removeTimeout, + }, })); }, 5000); // Start fade after 5 seconds - setToastStates(prev => ({ + setToastStates((prev) => ({ ...prev, [toastId]: { ...prev[toastId], - fadeTimeout - } + fadeTimeout, + }, })); } }; @@ -169,19 +169,34 @@ export function ToastContainer() { case 'error': return ( - + ); case 'warning': return ( - + ); default: return ( - + ); } @@ -191,7 +206,7 @@ export function ToastContainer() {
- {toasts.map(toast => { + {toasts.map((toast) => { const state = toastStates[toast.id] || { opacity: 1, isHovered: false }; return (
handleMouseEnter(toast.id)} onMouseLeave={() => handleMouseLeave(toast.id, toast.duration)} > -
-
- {getIcon(toast.type)} -
-

{toast.message}

- +
{getIcon(toast.type)}
+

{toast.message}

+ +
-
); })}
); -} \ No newline at end of file +} diff --git a/src/components/WorkItemDeleteDialog.tsx b/apps/v1/client/src/components/WorkItemDeleteDialog.tsx similarity index 78% rename from src/components/WorkItemDeleteDialog.tsx rename to apps/v1/client/src/components/WorkItemDeleteDialog.tsx index 3ec25fc2..5655f2dc 100644 --- a/src/components/WorkItemDeleteDialog.tsx +++ b/apps/v1/client/src/components/WorkItemDeleteDialog.tsx @@ -10,11 +10,11 @@ interface WorkItemDeleteDialogProps { onConfirm: (permanentDelete: boolean) => void; } -export function WorkItemDeleteDialog({ - isOpen, - onClose, +export function WorkItemDeleteDialog({ + isOpen, + onClose, workItem, - onConfirm + onConfirm, }: WorkItemDeleteDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -36,22 +36,22 @@ export function WorkItemDeleteDialog({ return (
{/* Backdrop */} -
- + {/* Modal */}
-
+ `} + >
-

- Delete Work Item -

+

Delete Work Item

@@ -59,7 +59,9 @@ export function WorkItemDeleteDialog({ Are you sure you want to delete "{workItem.title}"?

-
+
@@ -81,17 +83,16 @@ export function WorkItemDeleteDialog({
-
- @@ -100,4 +101,4 @@ export function WorkItemDeleteDialog({
); -} \ No newline at end of file +} diff --git a/src/components/WorkspaceConfirmDialog.tsx b/apps/v1/client/src/components/WorkspaceConfirmDialog.tsx similarity index 69% rename from src/components/WorkspaceConfirmDialog.tsx rename to apps/v1/client/src/components/WorkspaceConfirmDialog.tsx index 6008d691..765f6d48 100644 --- a/src/components/WorkspaceConfirmDialog.tsx +++ b/apps/v1/client/src/components/WorkspaceConfirmDialog.tsx @@ -8,7 +8,12 @@ interface WorkspaceConfirmDialogProps { onCancel: () => void; } -export function WorkspaceConfirmDialog({ isOpen, path, onConfirm, onCancel }: WorkspaceConfirmDialogProps) { +export function WorkspaceConfirmDialog({ + isOpen, + path, + onConfirm, + onCancel, +}: WorkspaceConfirmDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -18,14 +23,16 @@ export function WorkspaceConfirmDialog({ isOpen, path, onConfirm, onCancel }: Wo
{/* Backdrop */}
- + {/* Modal */}
-
+ `} + > {/* Header */}

@@ -36,20 +43,20 @@ export function WorkspaceConfirmDialog({ isOpen, path, onConfirm, onCancel }: Wo {/* Content */}
-

- The folder you specified does not exist: -

-
+

The folder you specified does not exist:

+
{path}
-

- Would you like to create this folder and set up your workspace there? -

+

Would you like to create this folder and set up your workspace there?

{/* Footer */} -
+
@@ -61,4 +68,4 @@ export function WorkspaceConfirmDialog({ isOpen, path, onConfirm, onCancel }: Wo
); -} \ No newline at end of file +} diff --git a/src/components/WorkspaceDialogContainer.tsx b/apps/v1/client/src/components/WorkspaceDialogContainer.tsx similarity index 99% rename from src/components/WorkspaceDialogContainer.tsx rename to apps/v1/client/src/components/WorkspaceDialogContainer.tsx index e603ab59..087a8128 100644 --- a/src/components/WorkspaceDialogContainer.tsx +++ b/apps/v1/client/src/components/WorkspaceDialogContainer.tsx @@ -29,10 +29,10 @@ export function WorkspaceDialogContainer({ isOpen, onComplete }: WorkspaceDialog const handleFolderCancel = () => { setShowFolderBrowser(false); }; - + const handleWorkspaceSelected = (path: string, hasExistingContent?: boolean) => { console.log('Workspace selected:', path, 'Has content:', hasExistingContent); - + if (hasExistingContent) { // Show importing dialog setSelectedWorkspacePath(path); @@ -42,7 +42,7 @@ export function WorkspaceDialogContainer({ isOpen, onComplete }: WorkspaceDialog onComplete(path); } }; - + const handleImportComplete = () => { onComplete(selectedWorkspacePath); }; @@ -51,7 +51,7 @@ export function WorkspaceDialogContainer({ isOpen, onComplete }: WorkspaceDialog
{/* Backdrop */}
- + {/* Show importing dialog if needed */} {showImporting ? ( ); -} \ No newline at end of file +} diff --git a/src/components/WorkspaceSetupDialog.tsx b/apps/v1/client/src/components/WorkspaceSetupDialog.tsx similarity index 57% rename from src/components/WorkspaceSetupDialog.tsx rename to apps/v1/client/src/components/WorkspaceSetupDialog.tsx index 4fad5b3d..3f56e853 100644 --- a/src/components/WorkspaceSetupDialog.tsx +++ b/apps/v1/client/src/components/WorkspaceSetupDialog.tsx @@ -12,7 +12,12 @@ interface WorkspaceSetupDialogProps { externalPath?: string; } -export function WorkspaceSetupDialog({ isOpen, onComplete, onBrowseFolder, externalPath = '' }: WorkspaceSetupDialogProps) { +export function WorkspaceSetupDialog({ + isOpen, + onComplete, + onBrowseFolder, + externalPath = '', +}: WorkspaceSetupDialogProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const [workspacePath, setWorkspacePath] = useState(externalPath); @@ -42,7 +47,7 @@ export function WorkspaceSetupDialog({ isOpen, onComplete, onBrowseFolder, exter headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ workspacePath: trimmedPath }) + body: JSON.stringify({ workspacePath: trimmedPath }), }); if (response.ok) { @@ -81,81 +86,85 @@ export function WorkspaceSetupDialog({ isOpen, onComplete, onBrowseFolder, exter // If using external dialog management, don't render backdrop const renderContent = () => ( -
- {/* Header */} -
-

- Welcome to Claude Flow -

-
+ `} + > + {/* Header */} +
+

Welcome to Claude Flow

+
- {/* Content */} -
-
-

- Claude Flow uses a workspace folder to organize your projects and collaborate with AI assistants. -

-

- Your workspace will contain: -

-
    -
  • Projects with their repositories
  • -
  • Work item plans and documentation
  • -
  • AI persona configurations
  • -
  • Jam session histories
  • -
-

- Choose a folder where you'd like to store your workspace. This can be changed later in settings. -

-
- -
- -
- setWorkspacePath(e.target.value)} - placeholder="/path/to/your/workspace" - className={` + {/* Content */} +
+
+

+ Claude Flow uses a workspace folder to organize your projects and collaborate with AI + assistants. +

+

Your workspace will contain:

+
    +
  • Projects with their repositories
  • +
  • Work item plans and documentation
  • +
  • AI persona configurations
  • +
  • Jam session histories
  • +
+

+ Choose a folder where you'd like to store your workspace. This can be changed later in + settings. +

+
+ +
+ +
+ setWorkspacePath(e.target.value)} + placeholder="/path/to/your/workspace" + className={` flex-1 px-3 py-2 ${styles.buttonRadius} ${styles.contentBg} ${styles.contentBorder} border ${styles.textColor} focus:ring-2 focus:ring-neutral-500 focus:border-neutral-500 `} + /> + (onBrowseFolder ? onBrowseFolder() : setShowFolderBrowser(true))} + aria-label="Browse for folder" + variant="secondary" + title="Browse for folder" + > + + - onBrowseFolder ? onBrowseFolder() : setShowFolderBrowser(true)} - aria-label="Browse for folder" - variant="secondary" - title="Browse for folder" - > - - - - -
-

- Enter the full path to your workspace folder (e.g., /home/user/workspace or C:\Users\Name\workspace) -

-
+ +
+

+ Enter the full path to your workspace folder (e.g., /home/user/workspace or + C:\Users\Name\workspace) +

+
+
- {/* Footer */} -
- -
+ {/* Footer */} +
+ +
); @@ -169,11 +178,9 @@ export function WorkspaceSetupDialog({ isOpen, onComplete, onBrowseFolder, exter
{/* Backdrop */}
- + {/* Modal */} -
- {renderContent()} -
+
{renderContent()}
); -} \ No newline at end of file +} diff --git a/src/components/WorkspaceSync.tsx b/apps/v1/client/src/components/WorkspaceSync.tsx similarity index 76% rename from src/components/WorkspaceSync.tsx rename to apps/v1/client/src/components/WorkspaceSync.tsx index 8456cc52..1112eb14 100644 --- a/src/components/WorkspaceSync.tsx +++ b/apps/v1/client/src/components/WorkspaceSync.tsx @@ -10,14 +10,16 @@ export function WorkspaceSync() { useEffect(() => { if (workspaceProjects && workspaceProjects.length > 0) { // Create a hash of the projects to check if they've changed - const projectsHash = JSON.stringify(workspaceProjects.map(p => ({ - name: p.name, - path: p.path, - isLoading: p.isLoading, - repositories: p.repositories, - plans: p.plans - }))); - + const projectsHash = JSON.stringify( + workspaceProjects.map((p) => ({ + name: p.name, + path: p.path, + isLoading: p.isLoading, + repositories: p.repositories, + plans: p.plans, + })) + ); + // Only sync if projects have actually changed if (projectsHash !== lastSyncedRef.current) { console.log('WorkspaceSync: Syncing projects from workspace to app'); @@ -28,4 +30,4 @@ export function WorkspaceSync() { }, [workspaceProjects, syncWorkspaceProjects]); return null; -} \ No newline at end of file +} diff --git a/src/components/chat/ChatBubble.tsx b/apps/v1/client/src/components/chat/ChatBubble.tsx similarity index 53% rename from src/components/chat/ChatBubble.tsx rename to apps/v1/client/src/components/chat/ChatBubble.tsx index 64086ae3..2c91663f 100644 --- a/src/components/chat/ChatBubble.tsx +++ b/apps/v1/client/src/components/chat/ChatBubble.tsx @@ -20,48 +20,45 @@ export const ChatBubble = memo(function ChatBubble({ name, timestamp, status, - className = '' + className = '', }: ChatBubbleProps) { const { currentStyles } = useTheme(); const styles = currentStyles; - + const isSent = variant === 'sent'; - + return ( -
- {!isSent && showAvatar && ( -
- {avatar} -
- )} - +
+ {!isSent && showAvatar &&
{avatar}
} +
- {!isSent && name && ( -
{name}
- )} - -
+ {!isSent && name &&
{name}
} + +
{children}
- + {(timestamp || status) && ( -
+
{timestamp && timestamp.toLocaleTimeString()} {status === 'sending' && Sending...} {status === 'error' && Failed to send}
)}
- - {isSent && showAvatar && ( -
- {avatar} -
- )} + + {isSent && showAvatar &&
{avatar}
}
); -}); \ No newline at end of file +}); diff --git a/src/components/chat/DiffView.tsx b/apps/v1/client/src/components/chat/DiffView.tsx similarity index 92% rename from src/components/chat/DiffView.tsx rename to apps/v1/client/src/components/chat/DiffView.tsx index dfcd3b62..9e387819 100644 --- a/src/components/chat/DiffView.tsx +++ b/apps/v1/client/src/components/chat/DiffView.tsx @@ -8,7 +8,11 @@ interface DiffViewProps { className?: string; } -export const DiffView = memo(function DiffView({ oldText, newText, className = '' }: DiffViewProps) { +export const DiffView = memo(function DiffView({ + oldText, + newText, + className = '', +}: DiffViewProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -49,4 +53,4 @@ export const DiffView = memo(function DiffView({ oldText, newText, className = '
); -}); \ No newline at end of file +}); diff --git a/src/components/chat/ProgressIndicator.tsx b/apps/v1/client/src/components/chat/ProgressIndicator.tsx similarity index 82% rename from src/components/chat/ProgressIndicator.tsx rename to apps/v1/client/src/components/chat/ProgressIndicator.tsx index bfac0f3a..692b222d 100644 --- a/src/components/chat/ProgressIndicator.tsx +++ b/apps/v1/client/src/components/chat/ProgressIndicator.tsx @@ -12,50 +12,61 @@ export const ProgressIndicator = memo(function ProgressIndicator({ startTime, tokenCount, status = 'Thinking', - onCancel + onCancel, }: ProgressIndicatorProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const [elapsedSeconds, setElapsedSeconds] = useState(0); - + useEffect(() => { const interval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime.getTime()) / 1000); setElapsedSeconds(elapsed); }, 1000); - + return () => clearInterval(interval); }, [startTime]); - + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && onCancel) { onCancel(); } }; - + window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onCancel]); - + const formatElapsedTime = (seconds: number) => { if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; }; - + const formatTokenCount = (count: number) => { if (count < 1000) return `${count} tokens`; return `${(count / 1000).toFixed(1)}k tokens`; }; - + return (
- - + +
{status}… @@ -68,4 +79,4 @@ export const ProgressIndicator = memo(function ProgressIndicator({
); -}); \ No newline at end of file +}); diff --git a/src/components/chat/SuggestedResponses.tsx b/apps/v1/client/src/components/chat/SuggestedResponses.tsx similarity index 97% rename from src/components/chat/SuggestedResponses.tsx rename to apps/v1/client/src/components/chat/SuggestedResponses.tsx index 28c29300..68be5ee5 100644 --- a/src/components/chat/SuggestedResponses.tsx +++ b/apps/v1/client/src/components/chat/SuggestedResponses.tsx @@ -10,13 +10,13 @@ interface SuggestedResponsesProps { export const SuggestedResponses = memo(function SuggestedResponses({ responses, onSelect, - disabled = false + disabled = false, }: SuggestedResponsesProps) { const { currentStyles } = useTheme(); const styles = currentStyles; - + if (!responses || responses.length === 0) return null; - + return (
{responses.map((response, index) => ( @@ -38,4 +38,4 @@ export const SuggestedResponses = memo(function SuggestedResponses({ ))}
); -}); \ No newline at end of file +}); diff --git a/src/components/chat/ToolExecution.tsx b/apps/v1/client/src/components/chat/ToolExecution.tsx similarity index 79% rename from src/components/chat/ToolExecution.tsx rename to apps/v1/client/src/components/chat/ToolExecution.tsx index 298ee01a..a1ccafa8 100644 --- a/src/components/chat/ToolExecution.tsx +++ b/apps/v1/client/src/components/chat/ToolExecution.tsx @@ -31,25 +31,27 @@ export const ToolExecution = memo(function ToolExecution({ 'data-testid': dataTestId, sessionId, messageId, - hideFeedbackLink = false + hideFeedbackLink = false, }: ToolExecutionProps) { const { currentStyles } = useTheme(); const styles = currentStyles; - + // Function to render file paths as clickable VS Code links const renderFilePathOrText = (text: string) => { // Check if the entire text is a file path const trimmedText = text.trim(); - const singlePathMatch = trimmedText.match(/^((?:\/[\w.-]+)+(?:\/[\w.-]+)*|(?:\.\/)?[\w.-]+(?:\/[\w.-]+)+)(?:\:(\d+))?$/); - + const singlePathMatch = trimmedText.match( + /^((?:\/[\w.-]+)+(?:\/[\w.-]+)*|(?:\.\/)?[\w.-]+(?:\/[\w.-]+)+)(?:\:(\d+))?$/ + ); + if (singlePathMatch) { const [, path, lineNumber] = singlePathMatch; - const vscodeUrl = lineNumber + const vscodeUrl = lineNumber ? `vscode://file${path}:${lineNumber}:1` : `vscode://file${path}`; - + return ( - { @@ -62,11 +64,11 @@ export const ToolExecution = memo(function ToolExecution({ ); } - + // For other text, show truncated version return text.length > 80 ? text.substring(0, 80) + '...' : text; }; - + // Helper function to create VS Code link const createVSCodeLink = (filePath: string, key: string, workingDir?: string) => { // Ensure the path is absolute @@ -80,7 +82,7 @@ export const ToolExecution = memo(function ToolExecution({ absolutePath = `/${filePath}`; } } - + const vscodeUrl = `vscode://file${absolutePath}`; return ( { // Check if line contains a file path (common patterns in git status output) // This pattern captures paths like "packages/apisurf/src/analyzers/analyzeNpmPackageVersions.ts" - const filePathPattern = /(?:modified:|new file:|deleted:|renamed:|copied:|updated:|typechange:|added:|untracked:)?\s*((?:[\w\-]+\/)*[\w\-]+\.[\w]+)/g; - + const filePathPattern = + /(?:modified:|new file:|deleted:|renamed:|copied:|updated:|typechange:|added:|untracked:)?\s*((?:[\w\-]+\/)*[\w\-]+\.[\w]+)/g; + let lastIndex = 0; const parts: React.ReactNode[] = []; let match; - + while ((match = filePathPattern.exec(line)) !== null) { // Add text before the match if (match.index > lastIndex) { @@ -117,31 +120,27 @@ export const ToolExecution = memo(function ToolExecution({ ); } - + // Add the file path as a link const filePath = match[1]; parts.push(createVSCodeLink(filePath, `link-${lineIndex}-${match.index}`, workingDir)); - + lastIndex = match.index + match[0].length; } - + // Add remaining text after the last match if (lastIndex < line.length) { - parts.push( - - {line.substring(lastIndex)} - - ); + parts.push({line.substring(lastIndex)}); } - + // If no matches, return the entire line if (parts.length === 0) { return line || '\u00A0'; } - + return parts; }; - + // Function to get working directory from context const getWorkingDirectory = () => { // Try to get the working directory from the URL params @@ -154,41 +153,39 @@ export const ToolExecution = memo(function ToolExecution({ } return undefined; }; - + // Function to render bash output with clickable file paths const renderBashOutput = (text: string) => { const lines = text.split('\n'); const workingDir = getWorkingDirectory(); - + return (
{lines.map((line, index) => ( -
- {parseLineForFilePaths(line, index, workingDir)} -
+
{parseLineForFilePaths(line, index, workingDir)}
))}
); }; - + // Format tool name to be more user-friendly const formatToolName = (toolName: string): string => { const nameMap: Record = { - 'TodoWrite': '📝 Update todo list', - 'Edit': '✏️ Edit file', - 'MultiEdit': '✏️ Edit multiple sections', - 'Read': '📖 Read file', - 'Write': '📄 Write file', - 'Bash': '💻 Run command', - 'Grep': '🔍 Search files', - 'Glob': '📁 Find files', - 'LS': '📋 List directory', - 'NotebookRead': '📓 Read notebook', - 'NotebookEdit': '📓 Edit notebook', - 'WebFetch': '🌐 Fetch web content', - 'WebSearch': '🔎 Search web', - 'Task': '🤖 Launch agent', - 'exit_plan_mode': '✅ Exit plan mode' + TodoWrite: '📝 Update todo list', + Edit: '✏️ Edit file', + MultiEdit: '✏️ Edit multiple sections', + Read: '📖 Read file', + Write: '📄 Write file', + Bash: '💻 Run command', + Grep: '🔍 Search files', + Glob: '📁 Find files', + LS: '📋 List directory', + NotebookRead: '📓 Read notebook', + NotebookEdit: '📓 Edit notebook', + WebFetch: '🌐 Fetch web content', + WebSearch: '🔎 Search web', + Task: '🤖 Launch agent', + exit_plan_mode: '✅ Exit plan mode', }; return nameMap[toolName] || toolName; }; @@ -197,7 +194,7 @@ export const ToolExecution = memo(function ToolExecution({ const startTimeRef = useRef(null); const intervalRef = useRef(null); const { projectId, repoName } = useParams<{ projectId: string; repoName: string }>(); - + // Set up feedback for this tool execution (only if sessionId is provided) const { showDialog, @@ -205,14 +202,14 @@ export const ToolExecution = memo(function ToolExecution({ error: feedbackError, openFeedback, closeFeedback, - submitFeedback + submitFeedback, } = useFeedback({ sessionId: sessionId || '', repoName: repoName || '', projectId: projectId || '', - messageId + messageId, }); - + // Track running duration useEffect(() => { if (status === 'running') { @@ -220,14 +217,14 @@ export const ToolExecution = memo(function ToolExecution({ if (!startTimeRef.current) { startTimeRef.current = Date.now(); } - + // Update duration every 100ms intervalRef.current = setInterval(() => { if (startTimeRef.current) { setRunningDuration(Date.now() - startTimeRef.current); } }, 100); // Update every 100ms for smoother display - + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -245,7 +242,7 @@ export const ToolExecution = memo(function ToolExecution({ } } }, [status, executionTime]); - + // Format duration for display const formatDuration = (ms: number) => { if (ms < 1000) { @@ -254,7 +251,7 @@ export const ToolExecution = memo(function ToolExecution({ return `${(ms / 1000).toFixed(1)}s`; } }; - + const getStatusIcon = () => { switch (status) { case 'pending': @@ -266,25 +263,48 @@ export const ToolExecution = memo(function ToolExecution({ case 'running': return ( - - + + ); case 'complete': return ( - + ); case 'error': return ( - + ); } }; - + const getStatusColor = () => { switch (status) { case 'pending': @@ -297,47 +317,61 @@ export const ToolExecution = memo(function ToolExecution({ return 'text-red-500'; } }; - + const formatOutput = (text: string) => { const lines = text.split('\n'); const maxLines = 5; const hasMore = lines.length > maxLines; - + if (!isExpanded && hasMore) { return { text: lines.slice(0, maxLines).join('\n'), hasMore: true, - moreCount: lines.length - maxLines + moreCount: lines.length - maxLines, }; } - + return { text, hasMore: false, - moreCount: 0 + moreCount: 0, }; }; - + const outputInfo = output ? formatOutput(output) : null; - + return ( -
+
-
+
{getStatusIcon()}
- {formatToolName(name)} - {args && name !== 'TodoWrite' && name !== 'Edit' && name !== 'MultiEdit' && name !== 'Grep' && name !== 'Glob' && name !== 'LS' && name !== 'Task' && name !== 'Read' && name !== 'Write' && ( - - {renderFilePathOrText(args)} - - )} + + {formatToolName(name)} + + {args && + name !== 'TodoWrite' && + name !== 'Edit' && + name !== 'MultiEdit' && + name !== 'Grep' && + name !== 'Glob' && + name !== 'LS' && + name !== 'Task' && + name !== 'Read' && + name !== 'Write' && ( + + {renderFilePathOrText(args)} + + )} {args && name === 'TodoWrite' && ( {(() => { @@ -357,16 +391,17 @@ export const ToolExecution = memo(function ToolExecution({ try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; const filePath = argsData.file_path || ''; - + if (filePath) { const vscodeUrl = `vscode://file${filePath}`; const fileName = filePath.split('/').pop() || filePath; - const displayText = name === 'MultiEdit' && argsData.edits?.length - ? `${fileName} (${argsData.edits.length} edit${argsData.edits.length === 1 ? '' : 's'})` - : fileName; - + const displayText = + name === 'MultiEdit' && argsData.edits?.length + ? `${fileName} (${argsData.edits.length} edit${argsData.edits.length === 1 ? '' : 's'})` + : fileName; + return ( - { @@ -379,7 +414,7 @@ export const ToolExecution = memo(function ToolExecution({ ); } - + return args.length > 80 ? args.substring(0, 80) + '...' : args; } catch { return args.length > 80 ? args.substring(0, 80) + '...' : args; @@ -393,13 +428,13 @@ export const ToolExecution = memo(function ToolExecution({ try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; const filePath = argsData.file_path || ''; - + if (filePath) { const vscodeUrl = `vscode://file${filePath}`; const fileName = filePath.split('/').pop() || filePath; - + return ( - { @@ -412,7 +447,7 @@ export const ToolExecution = memo(function ToolExecution({ ); } - + return args.length > 80 ? args.substring(0, 80) + '...' : args; } catch { return args.length > 80 ? args.substring(0, 80) + '...' : args; @@ -420,7 +455,8 @@ export const ToolExecution = memo(function ToolExecution({ })()} )} - {args && name === 'Grep' && ( + {args && + name === 'Grep' && (() => { try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; @@ -436,9 +472,9 @@ export const ToolExecution = memo(function ToolExecution({ ); } - })() - )} - {args && name === 'Glob' && ( + })()} + {args && + name === 'Glob' && (() => { try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; @@ -454,14 +490,15 @@ export const ToolExecution = memo(function ToolExecution({ ); } - })() - )} - {args && name === 'LS' && ( + })()} + {args && + name === 'LS' && (() => { try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; const path = argsData.path || '.'; - const displayPath = path === '.' ? 'current directory' : path.split('/').pop() || path; + const displayPath = + path === '.' ? 'current directory' : path.split('/').pop() || path; return ( {displayPath} ); @@ -472,16 +509,14 @@ export const ToolExecution = memo(function ToolExecution({ ); } - })() - )} - {args && name === 'Task' && ( + })()} + {args && + name === 'Task' && (() => { try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; const description = argsData.description || 'Running task'; - return ( - {description} - ); + return {description}; } catch { return ( @@ -489,19 +524,17 @@ export const ToolExecution = memo(function ToolExecution({ ); } - })() - )} + })()}
{(status === 'running' || status === 'complete' || executionTime) && ( - {status === 'running' + {status === 'running' ? formatDuration(runningDuration) - : formatDuration(executionTime || runningDuration || 0) - } + : formatDuration(executionTime || runningDuration || 0)} )}
- + {/* Grep tool details - show pattern */} {args && name === 'Grep' && (
@@ -520,7 +553,7 @@ export const ToolExecution = memo(function ToolExecution({ })()}
)} - + {/* Glob tool details - show pattern */} {args && name === 'Glob' && (
@@ -539,7 +572,7 @@ export const ToolExecution = memo(function ToolExecution({ })()}
)} - + {/* Task tool details - show prompt */} {args && name === 'Task' && (
@@ -549,11 +582,10 @@ export const ToolExecution = memo(function ToolExecution({ const prompt = argsData.prompt || ''; // Show first line or first 200 chars of prompt const firstLine = prompt.split('\n')[0]; - const displayPrompt = firstLine.length > 200 ? firstLine.substring(0, 200) + '...' : firstLine; + const displayPrompt = + firstLine.length > 200 ? firstLine.substring(0, 200) + '...' : firstLine; return ( -
- Task: {displayPrompt} -
+
Task: {displayPrompt}
); } catch { return null; @@ -561,22 +593,24 @@ export const ToolExecution = memo(function ToolExecution({ })()}
)} - + {/* Edit tool details - show before/after */} {args && (name === 'Edit' || name === 'MultiEdit') && status === 'complete' && (
{(() => { try { const argsData = typeof args === 'string' ? JSON.parse(args) : args; - + if (name === 'Edit') { // Single edit return (
-
Changes:
- + Changes: +
+
); @@ -584,18 +618,18 @@ export const ToolExecution = memo(function ToolExecution({ // MultiEdit - show first edit only with count const edits = argsData.edits || []; if (edits.length === 0) return null; - + const firstEdit = edits[0]; const showCount = edits.length > 1; - + return (
Changes{showCount ? ` (edit 1 of ${edits.length})` : ''}:
- {showCount && (
@@ -611,21 +645,27 @@ export const ToolExecution = memo(function ToolExecution({ })()}
)} - + {error && (
-
Error:
-
+              
+ Error: +
+
                 {error}
               
)} - + {outputInfo && (
{name === 'LS' && output ? ( -
+
{(() => { try { // Try to parse as JSON array @@ -637,7 +677,11 @@ export const ToolExecution = memo(function ToolExecution({
{displayItems.map((item, index) => (
- + {item.type === 'directory' ? '📁' : '📄'} {item.name} @@ -664,7 +708,9 @@ export const ToolExecution = memo(function ToolExecution({ ); } else { // Not an array, show as plain text - return
{outputInfo.text}
; + return ( +
{outputInfo.text}
+ ); } } catch { // Failed to parse as JSON, show as plain text @@ -673,11 +719,15 @@ export const ToolExecution = memo(function ToolExecution({ })()}
) : name === 'Bash' ? ( -
+
{renderBashOutput(outputInfo.text)}
) : ( -
+                
                   {outputInfo.text}
                 
)} @@ -693,14 +743,14 @@ export const ToolExecution = memo(function ToolExecution({ )}
- + {/* Feedback link - only show if we have session context and not hidden */} {sessionId && status === 'complete' && !hideFeedbackLink && (
)} - + {/* Feedback dialogs */} {sessionId && ( <> @@ -711,9 +761,8 @@ export const ToolExecution = memo(function ToolExecution({ isSubmitting={isSubmitting} error={feedbackError} /> - )}
); -}); \ No newline at end of file +}); diff --git a/src/components/chat/ToolExecutionGroup.tsx b/apps/v1/client/src/components/chat/ToolExecutionGroup.tsx similarity index 84% rename from src/components/chat/ToolExecutionGroup.tsx rename to apps/v1/client/src/components/chat/ToolExecutionGroup.tsx index 3a524d99..81f3b5cc 100644 --- a/src/components/chat/ToolExecutionGroup.tsx +++ b/apps/v1/client/src/components/chat/ToolExecutionGroup.tsx @@ -14,12 +14,12 @@ interface ToolExecutionGroupProps { export const ToolExecutionGroup = memo(function ToolExecutionGroup({ tools, - sessionId + sessionId, }: ToolExecutionGroupProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const { projectId, repoName } = useParams<{ projectId: string; repoName: string }>(); - + // Set up feedback for this tool group (use first tool's ID as the messageId) const { showDialog, @@ -27,20 +27,26 @@ export const ToolExecutionGroup = memo(function ToolExecutionGroup({ error: feedbackError, openFeedback, closeFeedback, - submitFeedback + submitFeedback, } = useFeedback({ sessionId: sessionId || '', repoName: repoName || '', projectId: projectId || '', - messageId: tools[0]?.id + messageId: tools[0]?.id, }); - + if (tools.length === 0) return null; - + return ( -
-
- {tools.length} tool execution{tools.length === 1 ? '' : 's'} +
+
+ + {tools.length} tool execution{tools.length === 1 ? '' : 's'} +
@@ -54,7 +60,7 @@ export const ToolExecutionGroup = memo(function ToolExecutionGroup({ toolName = 'Tool execution'; } } - + return (
- + {/* Feedback dialog */}
); -}); \ No newline at end of file +}); diff --git a/src/components/claude-code/ClaudeInput.tsx b/apps/v1/client/src/components/claude-code/ClaudeInput.tsx similarity index 69% rename from src/components/claude-code/ClaudeInput.tsx rename to apps/v1/client/src/components/claude-code/ClaudeInput.tsx index d266eb62..a0534be0 100644 --- a/src/components/claude-code/ClaudeInput.tsx +++ b/apps/v1/client/src/components/claude-code/ClaudeInput.tsx @@ -17,13 +17,13 @@ export const ClaudeInput = memo(function ClaudeInput({ mode, contextUsage, isSubmitting, - isConnected + isConnected, }: ClaudeInputProps) { const { currentStyles } = useTheme(); const styles = currentStyles; const textareaRef = useRef(null); const [value, setValue] = useState(''); - + // Auto-resize textarea useEffect(() => { if (textareaRef.current) { @@ -31,7 +31,7 @@ export const ClaudeInput = memo(function ClaudeInput({ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; } }, [value]); - + // Clear input after successful submission useEffect(() => { if (!isSubmitting && value === '') { @@ -39,27 +39,30 @@ export const ClaudeInput = memo(function ClaudeInput({ setValue(''); } }, [isSubmitting]); - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - // Shift+Tab to toggle between Plan and Execute modes - if (e.key === 'Tab' && e.shiftKey) { - e.preventDefault(); - // Toggle between plan and execute (default) modes only - const newMode = mode === 'plan' ? 'default' : 'plan'; - onModeChange(newMode); - return; - } - - // Enter to submit (Shift+Enter for new line) - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (value.trim() && !isSubmitting && isConnected) { - onSubmit(value); - setValue(''); // Clear input after submission + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Shift+Tab to toggle between Plan and Execute modes + if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + // Toggle between plan and execute (default) modes only + const newMode = mode === 'plan' ? 'default' : 'plan'; + onModeChange(newMode); + return; } - } - }, [mode, onModeChange, value, isSubmitting, isConnected, onSubmit]); - + + // Enter to submit (Shift+Enter for new line) + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (value.trim() && !isSubmitting && isConnected) { + onSubmit(value); + setValue(''); // Clear input after submission + } + } + }, + [mode, onModeChange, value, isSubmitting, isConnected, onSubmit] + ); + const getModeColor = () => { switch (mode) { case 'auto-accept': @@ -70,7 +73,7 @@ export const ClaudeInput = memo(function ClaudeInput({ return styles.mutedText; } }; - + const getModeIcon = () => { switch (mode) { case 'auto-accept': @@ -81,13 +84,13 @@ export const ClaudeInput = memo(function ClaudeInput({ return '💬'; } }; - + const getContextColor = () => { if (contextUsage >= 90) return 'text-red-600 dark:text-red-400'; if (contextUsage >= 70) return 'text-yellow-600 dark:text-yellow-400'; return styles.mutedText; }; - + return (
@@ -96,7 +99,11 @@ export const ClaudeInput = memo(function ClaudeInput({ value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} - placeholder={isConnected ? "Type a message... (Enter to send, Shift+Enter for new line)" : "Connecting..."} + placeholder={ + isConnected + ? 'Type a message... (Enter to send, Shift+Enter for new line)' + : 'Connecting...' + } disabled={!isConnected || isSubmitting} className={` w-full px-4 py-3 pr-24 @@ -109,7 +116,7 @@ export const ClaudeInput = memo(function ClaudeInput({ style={{ minHeight: '56px' }} data-testid="message-input" /> - + {/* Submit button */}
- + {/* Status bar */}
{/* Mode indicator */}
{getModeIcon()} - - {mode === 'plan' ? 'Planning' : 'Execute'} - + {mode === 'plan' ? 'Planning' : 'Execute'}
- - Shift+Tab to toggle mode - + Shift+Tab to toggle mode
- + {/* Context usage */}
Context:
-
-
+
= 90 ? 'bg-red-500' : - contextUsage >= 70 ? 'bg-yellow-500' : - 'bg-green-500' + contextUsage >= 90 + ? 'bg-red-500' + : contextUsage >= 70 + ? 'bg-yellow-500' + : 'bg-green-500' }`} style={{ width: `${contextUsage}%` }} /> @@ -174,4 +187,4 @@ export const ClaudeInput = memo(function ClaudeInput({
); -}); \ No newline at end of file +}); diff --git a/src/components/claude-code/ClaudeMessage.tsx b/apps/v1/client/src/components/claude-code/ClaudeMessage.tsx similarity index 81% rename from src/components/claude-code/ClaudeMessage.tsx rename to apps/v1/client/src/components/claude-code/ClaudeMessage.tsx index d2d134d9..6f35a1e4 100644 --- a/src/components/claude-code/ClaudeMessage.tsx +++ b/apps/v1/client/src/components/claude-code/ClaudeMessage.tsx @@ -17,39 +17,35 @@ interface ClaudeMessageProps { sessionId: string; } -export const ClaudeMessage = memo(function ClaudeMessage({ - message, +export const ClaudeMessage = memo(function ClaudeMessage({ + message, onSuggestedResponse, isLatestAssistantMessage = false, - sessionId + sessionId, }: ClaudeMessageProps) { const { projectId, repoName } = useParams<{ projectId: string; repoName: string }>(); - + // Set up feedback for this message - const { - showDialog, - isSubmitting, - error, - openFeedback, - closeFeedback, - submitFeedback - } = useFeedback({ - sessionId, - repoName: repoName || '', - projectId: projectId || '', - messageId: message.id - }); + const { showDialog, isSubmitting, error, openFeedback, closeFeedback, submitFeedback } = + useFeedback({ + sessionId, + repoName: repoName || '', + projectId: projectId || '', + messageId: message.id, + }); // Check if this is an error message - const isErrorMessage = message.content.startsWith('API Error:') || message.content.includes('authentication_error'); - const is401Error = message.content.includes('401') && message.content.includes('authentication_error'); - + const isErrorMessage = + message.content.startsWith('API Error:') || message.content.includes('authentication_error'); + const is401Error = + message.content.includes('401') && message.content.includes('authentication_error'); + // Handle content that might be an array or object let messageContent = message.content; if (typeof messageContent !== 'string') { if (Array.isArray(messageContent)) { messageContent = (messageContent as Array) - .filter(block => block?.type === 'text') - .map(block => block?.text || '') + .filter((block) => block?.type === 'text') + .map((block) => block?.text || '') .join('\n'); } else if (messageContent && typeof messageContent === 'object' && 'text' in messageContent) { messageContent = (messageContent as any).text; @@ -57,38 +53,54 @@ export const ClaudeMessage = memo(function ClaudeMessage({ messageContent = String(messageContent); } } - + // Clean up message content to remove artifacts from malformed API responses if (typeof messageContent === 'string') { // Remove "undefined" at the start of messages messageContent = messageContent.replace(/^undefined\s*\n?/i, ''); - + // Remove "H:" and "A:" conversation prefixes that shouldn't be visible messageContent = messageContent.replace(/^(H|A):\s*(.+?)(?=\n\n(H|A):|$)/gm, '$2'); - + // Clean up any remaining leading/trailing whitespace messageContent = messageContent.trim(); } - - const formatContent = useMemo(() => (content: string) => { - const html = parseMarkdown(content); - return
; - }, []); - + + const formatContent = useMemo( + () => (content: string) => { + const html = parseMarkdown(content); + return ( +
+ ); + }, + [] + ); + // Show thinking indicator for streaming messages const isThinking = message.isStreaming && messageContent.startsWith('Claude is thinking'); - + const getAvatar = () => { switch (message.role) { case 'user': - return
U
; + return ( +
+ U +
+ ); case 'assistant': - return
C
; + return ( +
+ C +
+ ); case 'system': return null; } }; - + // System messages get special treatment if (message.role === 'system') { return ( @@ -97,7 +109,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({
); } - + // Tool messages are rendered as tool executions if (message.role === 'tool') { // Extract tool name from message ID if name is not provided @@ -109,7 +121,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({ toolName = 'Tool execution'; } } - + return ( <>
@@ -125,7 +137,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({ messageId={message.id} />
- + {/* Feedback dialog for tool execution */} ); } - + // Render error messages with special styling if (isErrorMessage) { return ( @@ -145,8 +157,18 @@ export const ClaudeMessage = memo(function ClaudeMessage({
- - + +
@@ -165,7 +187,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({
{is401Error && (
- + {/* Feedback link */} {!message.isStreaming && (
@@ -181,7 +203,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({
)}
- + {/* Feedback dialog */} ); } - + return ( <> )} - + {/* Planning mode indicator for user messages */} {message.role === 'user' && message.mode === 'plan' && (
@@ -228,7 +250,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({
)} - + {/* Suggested responses for the latest assistant message */} {isLatestAssistantMessage && message.suggestedResponses && onSuggestedResponse && ( )} - + {/* Feedback link */} {!message.isStreaming && (
@@ -245,7 +267,7 @@ export const ClaudeMessage = memo(function ClaudeMessage({
)}
- + {/* Feedback dialog */} ); -}); \ No newline at end of file +}); diff --git a/src/components/claude-code/TodoList.tsx b/apps/v1/client/src/components/claude-code/TodoList.tsx similarity index 65% rename from src/components/claude-code/TodoList.tsx rename to apps/v1/client/src/components/claude-code/TodoList.tsx index 1bf50860..10faf196 100644 --- a/src/components/claude-code/TodoList.tsx +++ b/apps/v1/client/src/components/claude-code/TodoList.tsx @@ -11,19 +11,19 @@ interface TodoListProps { export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListProps) { const { currentStyles } = useTheme(); const styles = currentStyles; - + // Build hierarchy const buildHierarchy = (todos: Todo[]): Todo[] => { const todoMap = new Map(); const rootTodos: Todo[] = []; - + // First pass: create map - todos.forEach(todo => { + todos.forEach((todo) => { todoMap.set(todo.id, { ...todo, children: [] }); }); - + // Second pass: build hierarchy - todos.forEach(todo => { + todos.forEach((todo) => { const todoWithChildren = todoMap.get(todo.id)!; if (todo.parentId && todoMap.has(todo.parentId)) { const parent = todoMap.get(todo.parentId)!; @@ -33,37 +33,51 @@ export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListPro rootTodos.push(todoWithChildren); } }); - + return rootTodos; }; - + const hierarchicalTodos = buildHierarchy(todos); - + // Group todos by status (considering only root level for grouping) - const todosByStatus = hierarchicalTodos.reduce((acc, todo) => { - if (!acc[todo.status]) { - acc[todo.status] = []; - } - acc[todo.status].push(todo); - return acc; - }, {} as Record); - - + const todosByStatus = hierarchicalTodos.reduce( + (acc, todo) => { + if (!acc[todo.status]) { + acc[todo.status] = []; + } + acc[todo.status].push(todo); + return acc; + }, + {} as Record + ); + const getStatusIcon = (status: string) => { switch (status) { case 'completed': return ( - - + + ); case 'in_progress': - return ( - - ); + return ; case 'pending': return ( - + ); @@ -71,10 +85,10 @@ export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListPro return null; } }; - + const renderTodoItem = (todo: Todo, indent: number = 0): React.ReactNode => { const isCompleted = todo.status === 'completed'; - + return (
{getStatusIcon(todo.status)} -

+

{todo.content}

{todo.children && todo.children.length > 0 && ( - <> - {todo.children.map(child => renderTodoItem(child, indent + 1))} - + <>{todo.children.map((child) => renderTodoItem(child, indent + 1))} )} ); }; - + if (todos.length === 0) { return ( -

@@ -106,16 +120,16 @@ export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListPro

); } - + return ( -
-
+
-

- Task list -

+

Task list

{todosByStatus.completed?.length || 0}/{todos.length} completed @@ -126,7 +140,13 @@ export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListPro className={`p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors`} title="Dismiss task list" > - + @@ -134,17 +154,17 @@ export const TodoList = memo(function TodoList({ todos, onDismiss }: TodoListPro
- +
{/* Show completed items first - they bubble to the top */} - {todosByStatus.completed?.map(todo => renderTodoItem(todo))} - + {todosByStatus.completed?.map((todo) => renderTodoItem(todo))} + {/* Then in-progress items */} - {todosByStatus.in_progress?.map(todo => renderTodoItem(todo))} - + {todosByStatus.in_progress?.map((todo) => renderTodoItem(todo))} + {/* Finally pending items */} - {todosByStatus.pending?.map(todo => renderTodoItem(todo))} + {todosByStatus.pending?.map((todo) => renderTodoItem(todo))}
); -}); \ No newline at end of file +}); diff --git a/src/components/claude-code/VirtualMessageList.tsx b/apps/v1/client/src/components/claude-code/VirtualMessageList.tsx similarity index 71% rename from src/components/claude-code/VirtualMessageList.tsx rename to apps/v1/client/src/components/claude-code/VirtualMessageList.tsx index d9dd94b3..f09bda1e 100644 --- a/src/components/claude-code/VirtualMessageList.tsx +++ b/apps/v1/client/src/components/claude-code/VirtualMessageList.tsx @@ -17,18 +17,26 @@ interface GroupedMessage { id: string; } -export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedResponse, sessionId }: VirtualMessageListProps) { +export function VirtualMessageList({ + messages, + scrollContainerRef, + onSuggestedResponse, + sessionId, +}: VirtualMessageListProps) { console.log('VirtualMessageList render, messages count:', messages.length); - console.log('Messages:', messages.map(m => ({ - ...m, - content: m.content.substring(0, 50) + (m.content.length > 50 ? '...' : '') - }))); - + console.log( + 'Messages:', + messages.map((m) => ({ + ...m, + content: m.content.substring(0, 50) + (m.content.length > 50 ? '...' : ''), + })) + ); + const measurementsCache = useRef>({}); const shouldAutoScroll = useRef(true); const isUserScrolling = useRef(false); const scrollTimeoutRef = useRef(undefined); - + // Group consecutive tool messages const groupedMessages = useMemo(() => { // First, sort messages by timestamp to ensure consistent order @@ -37,10 +45,10 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe const timeB = new Date(b.timestamp).getTime(); return timeA - timeB; }); - + const grouped: GroupedMessage[] = []; let currentToolGroup: ClaudeMessageType[] = []; - + sortedMessages.forEach((message) => { if (message.role === 'tool') { currentToolGroup.push(message); @@ -50,7 +58,7 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe grouped.push({ type: 'toolGroup', messages: currentToolGroup, - id: `tool-group-${currentToolGroup[0].id}` + id: `tool-group-${currentToolGroup[0].id}`, }); currentToolGroup = []; } @@ -58,57 +66,61 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe grouped.push({ type: 'single', messages: [message], - id: message.id + id: message.id, }); } }); - + // Don't forget the last group if it's tools if (currentToolGroup.length > 0) { grouped.push({ type: 'toolGroup', messages: currentToolGroup, - id: `tool-group-${currentToolGroup[0].id}` + id: `tool-group-${currentToolGroup[0].id}`, }); } - + return grouped; }, [messages]); - + // Estimate initial size based on message content - const estimateSize = useCallback((index: number) => { - const group = groupedMessages[index]; - if (!group) return 150; // Default height - - // Use cached measurement if available - if (measurementsCache.current[group.id]) { - return measurementsCache.current[group.id]; - } - - if (group.type === 'toolGroup') { - // Estimate height for tool group - const baseHeight = 40; // Header height - const toolHeight = 60; // Height per tool - return baseHeight + (group.messages.length * toolHeight); - } else { - // Single message estimation - const message = group.messages[0]; - // Rough estimation based on content length and type - const baseHeight = 80; // Base height for message wrapper - const charPerLine = 80; - const lineHeight = 24; - const codeBlockHeight = 200; // Estimated height for code blocks - - let contentLines = Math.ceil(message.content.length / charPerLine); - - // Add extra height for code blocks - const codeBlockCount = (message.content.match(/```/g) || []).length / 2; - const estimatedHeight = baseHeight + (contentLines * lineHeight) + (codeBlockCount * codeBlockHeight); - - return Math.min(estimatedHeight, 800); // Cap at reasonable max height - } - }, [groupedMessages]); - + const estimateSize = useCallback( + (index: number) => { + const group = groupedMessages[index]; + if (!group) return 150; // Default height + + // Use cached measurement if available + if (measurementsCache.current[group.id]) { + return measurementsCache.current[group.id]; + } + + if (group.type === 'toolGroup') { + // Estimate height for tool group + const baseHeight = 40; // Header height + const toolHeight = 60; // Height per tool + return baseHeight + group.messages.length * toolHeight; + } else { + // Single message estimation + const message = group.messages[0]; + // Rough estimation based on content length and type + const baseHeight = 80; // Base height for message wrapper + const charPerLine = 80; + const lineHeight = 24; + const codeBlockHeight = 200; // Estimated height for code blocks + + const contentLines = Math.ceil(message.content.length / charPerLine); + + // Add extra height for code blocks + const codeBlockCount = (message.content.match(/```/g) || []).length / 2; + const estimatedHeight = + baseHeight + contentLines * lineHeight + codeBlockCount * codeBlockHeight; + + return Math.min(estimatedHeight, 800); // Cap at reasonable max height + } + }, + [groupedMessages] + ); + const rowVirtualizer = useVirtualizer({ count: groupedMessages.length, getScrollElement: () => scrollContainerRef.current, @@ -124,7 +136,7 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe return htmlElement.offsetHeight; }, }); - + // Handle auto-scroll to bottom for new messages useEffect(() => { if (shouldAutoScroll.current && messages.length > 0 && scrollContainerRef.current) { @@ -133,41 +145,41 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; } }; - + // Use RAF to ensure DOM has updated requestAnimationFrame(() => { requestAnimationFrame(scrollToBottom); }); } }, [messages.length, scrollContainerRef]); - + // Detect user scrolling useEffect(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) return; - + const handleScroll = () => { isUserScrolling.current = true; - + // Clear existing timeout if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } - + // Check if scrolled to bottom const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold - + shouldAutoScroll.current = isAtBottom; - + // Reset user scrolling flag after scroll ends scrollTimeoutRef.current = setTimeout(() => { isUserScrolling.current = false; }, 150); }; - + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); - + return () => { scrollContainer.removeEventListener('scroll', handleScroll); if (scrollTimeoutRef.current) { @@ -175,10 +187,10 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe } }; }, [scrollContainerRef]); - + const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); - + return (
{virtualItems.map((virtualItem) => { const group = groupedMessages[virtualItem.index]; - + return (
{group.type === 'toolGroup' ? ( - + ) : ( - m.role === 'assistant' && m.id === group.messages[0].id) === messages.length - 1 + messages.findLastIndex( + (m) => m.role === 'assistant' && m.id === group.messages[0].id + ) === + messages.length - 1 } sessionId={sessionId} /> @@ -227,4 +239,4 @@ export function VirtualMessageList({ messages, scrollContainerRef, onSuggestedRe })}
); -} \ No newline at end of file +} diff --git a/src/components/ui/Breadcrumb.tsx b/apps/v1/client/src/components/ui/Breadcrumb.tsx similarity index 71% rename from src/components/ui/Breadcrumb.tsx rename to apps/v1/client/src/components/ui/Breadcrumb.tsx index 76ddac22..d3103ac8 100644 --- a/src/components/ui/Breadcrumb.tsx +++ b/apps/v1/client/src/components/ui/Breadcrumb.tsx @@ -16,34 +16,32 @@ export function Breadcrumb({ items }: BreadcrumbProps) { ); -} \ No newline at end of file +} diff --git a/src/components/ui/Button.tsx b/apps/v1/client/src/components/ui/Button.tsx similarity index 89% rename from src/components/ui/Button.tsx rename to apps/v1/client/src/components/ui/Button.tsx index ae29f82b..2f1f10ce 100644 --- a/src/components/ui/Button.tsx +++ b/apps/v1/client/src/components/ui/Button.tsx @@ -13,14 +13,25 @@ interface ButtonProps extends React.ButtonHTMLAttributes { } export const Button = forwardRef( - ({ variant = 'secondary', size = 'md', fullWidth = false, className = '', children, as: Component = 'button', ...props }, ref) => { + ( + { + variant = 'secondary', + size = 'md', + fullWidth = false, + className = '', + children, + as: Component = 'button', + ...props + }, + ref + ) => { const { currentStyles } = useTheme(); const styles = currentStyles; const sizeClasses = { sm: 'px-3 h-8 text-sm', md: 'px-4 h-10', - lg: 'px-6 h-12 text-lg' + lg: 'px-6 h-12 text-lg', }; const variantClasses = { @@ -29,11 +40,11 @@ export const Button = forwardRef( ghost: `${styles.textColor} hover:${styles.contentBg} hover:opacity-80 font-medium`, circular: `bg-white/80 dark:bg-neutral-800/80 ${styles.textColor} border border-neutral-200/50 dark:border-neutral-700/50 shadow-sm hover:bg-white/90 dark:hover:bg-neutral-800/90 font-medium`, outline: `${styles.contentBg} ${styles.contentBorder} border ${styles.textColor} hover:bg-gray-100 dark:hover:bg-gray-700`, - danger: `bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800 border border-transparent font-medium` + danger: `bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800 border border-transparent font-medium`, }; const isIconButton = className.includes('icon-button'); - + return ( ( Button.displayName = 'Button'; -export type { ButtonProps }; \ No newline at end of file +export type { ButtonProps }; diff --git a/src/components/ui/Checkbox.tsx b/apps/v1/client/src/components/ui/Checkbox.tsx similarity index 96% rename from src/components/ui/Checkbox.tsx rename to apps/v1/client/src/components/ui/Checkbox.tsx index 1442dcb6..80bc6776 100644 --- a/src/components/ui/Checkbox.tsx +++ b/apps/v1/client/src/components/ui/Checkbox.tsx @@ -39,4 +39,4 @@ export const Checkbox = forwardRef( } ); -Checkbox.displayName = 'Checkbox'; \ No newline at end of file +Checkbox.displayName = 'Checkbox'; diff --git a/src/components/ui/CrossFade.tsx b/apps/v1/client/src/components/ui/CrossFade.tsx similarity index 83% rename from src/components/ui/CrossFade.tsx rename to apps/v1/client/src/components/ui/CrossFade.tsx index 4aee4e4d..146f1b8f 100644 --- a/src/components/ui/CrossFade.tsx +++ b/apps/v1/client/src/components/ui/CrossFade.tsx @@ -13,11 +13,15 @@ interface ImageState { key: string; } -export function CrossFade({ src, alt = '', className = '', duration = 300, fallbackSrc }: CrossFadeProps) { +export function CrossFade({ + src, + alt = '', + className = '', + duration = 300, + fallbackSrc, +}: CrossFadeProps) { // Keep track of both current and previous images - const [images, setImages] = useState([ - { src, key: `img-0` } - ]); + const [images, setImages] = useState([{ src, key: `img-0` }]); const [activeIndex, setActiveIndex] = useState(0); const timeoutRef = useRef(null); const imageCountRef = useRef(1); @@ -27,25 +31,25 @@ export function CrossFade({ src, alt = '', className = '', duration = 300, fallb // When src changes, add new image and start transition if (src !== currentSrcRef.current) { currentSrcRef.current = src; - + // Preload the new image const img = new Image(); img.src = src; - + // Clear any pending timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } - + // Add new image and immediately set it as active const newKey = `img-${imageCountRef.current++}`; - setImages(prev => { + setImages((prev) => { // Always keep exactly 2 images during transition // Use the last image in the array as the current one const currentImage = prev[prev.length - 1]; return [currentImage, { src, key: newKey }]; }); - + // Start transition to new image after a frame to ensure DOM update requestAnimationFrame(() => { setActiveIndex(1); @@ -53,7 +57,7 @@ export function CrossFade({ src, alt = '', className = '', duration = 300, fallb // Clean up after transition completes timeoutRef.current = setTimeout(() => { - setImages(prev => { + setImages((prev) => { // Keep only the currently visible image const activeImage = prev[1] || prev[0]; return [activeImage]; @@ -71,7 +75,7 @@ export function CrossFade({ src, alt = '', className = '', duration = 300, fallb const handleError = (index: number) => { if (fallbackSrc) { - setImages(prev => { + setImages((prev) => { const newImages = [...prev]; newImages[index] = { ...newImages[index], src: fallbackSrc }; return newImages; @@ -82,8 +86,13 @@ export function CrossFade({ src, alt = '', className = '', duration = 300, fallb return (
{/* Invisible img to maintain aspect ratio */} - - + + {/* Actual crossfading images */} {images.map((img, index) => ( handleError(index)} /> ))}
); -} \ No newline at end of file +} diff --git a/src/components/ui/DancingBubbles.tsx b/apps/v1/client/src/components/ui/DancingBubbles.tsx similarity index 86% rename from src/components/ui/DancingBubbles.tsx rename to apps/v1/client/src/components/ui/DancingBubbles.tsx index 4df781e1..da689ff1 100644 --- a/src/components/ui/DancingBubbles.tsx +++ b/apps/v1/client/src/components/ui/DancingBubbles.tsx @@ -6,19 +6,19 @@ interface DancingBubblesProps { className?: string; } -export const DancingBubbles = memo(function DancingBubbles({ +export const DancingBubbles = memo(function DancingBubbles({ size = 'small', color = 'bg-neutral-400', - className = '' + className = '', }: DancingBubblesProps) { const sizeClasses = { small: 'w-2 h-2', medium: 'w-2.5 h-2.5', - large: 'w-3 h-3' + large: 'w-3 h-3', }; - + const bubbleClass = `${sizeClasses[size]} ${color} rounded-full animate-bounce`; - + return (
@@ -26,4 +26,4 @@ export const DancingBubbles = memo(function DancingBubbles({
); -}); \ No newline at end of file +}); diff --git a/src/components/ui/IconButton.tsx b/apps/v1/client/src/components/ui/IconButton.tsx similarity index 92% rename from src/components/ui/IconButton.tsx rename to apps/v1/client/src/components/ui/IconButton.tsx index 25b1f66a..e0cb2518 100644 --- a/src/components/ui/IconButton.tsx +++ b/apps/v1/client/src/components/ui/IconButton.tsx @@ -11,7 +11,7 @@ export const IconButton = forwardRef( const sizeClasses = { sm: 'h-8 w-8', md: 'h-10 w-10', - lg: 'h-12 w-12' + lg: 'h-12 w-12', }; const isCircular = variant === 'circular'; @@ -29,4 +29,4 @@ export const IconButton = forwardRef( } ); -IconButton.displayName = 'IconButton'; \ No newline at end of file +IconButton.displayName = 'IconButton'; diff --git a/src/components/ui/Input.tsx b/apps/v1/client/src/components/ui/Input.tsx similarity index 98% rename from src/components/ui/Input.tsx rename to apps/v1/client/src/components/ui/Input.tsx index d9a123b0..588bfcf2 100644 --- a/src/components/ui/Input.tsx +++ b/apps/v1/client/src/components/ui/Input.tsx @@ -58,4 +58,4 @@ export const Input = forwardRef( } ); -Input.displayName = 'Input'; \ No newline at end of file +Input.displayName = 'Input'; diff --git a/src/components/ui/LoadingSpinner.tsx b/apps/v1/client/src/components/ui/LoadingSpinner.tsx similarity index 62% rename from src/components/ui/LoadingSpinner.tsx rename to apps/v1/client/src/components/ui/LoadingSpinner.tsx index 8a28e7eb..0e2af5a6 100644 --- a/src/components/ui/LoadingSpinner.tsx +++ b/apps/v1/client/src/components/ui/LoadingSpinner.tsx @@ -8,12 +8,12 @@ interface LoadingSpinnerProps { variant?: 'default' | 'primary' | 'neutral'; } -export function LoadingSpinner({ - size = 'medium', - text, +export function LoadingSpinner({ + size = 'medium', + text, showContainer = false, className = '', - variant = 'default' + variant = 'default', }: LoadingSpinnerProps) { const { currentStyles, isDarkMode } = useTheme(); const styles = currentStyles; @@ -23,44 +23,45 @@ export function LoadingSpinner({ small: { spinner: 'h-4 w-4 border-2', container: 'p-4', - textSize: 'text-sm' + textSize: 'text-sm', }, medium: { spinner: 'h-8 w-8 border-2', container: 'p-8', - textSize: 'text-base' + textSize: 'text-base', }, large: { spinner: 'h-12 w-12 border-b-2', container: 'p-12', - textSize: 'text-lg' - } + textSize: 'text-lg', + }, }; // Determine spinner color based on variant - const spinnerColorClass = variant === 'primary' - ? 'text-white' - : variant === 'neutral' - ? `${isDarkMode ? 'text-neutral-300' : 'text-neutral-600'}` - : `${styles.textColor}`; + const spinnerColorClass = + variant === 'primary' + ? 'text-white' + : variant === 'neutral' + ? `${isDarkMode ? 'text-neutral-300' : 'text-neutral-600'}` + : `${styles.textColor}`; const spinnerElement = (
-
- {text && ( -

- {text} -

- )} +
+ {text &&

{text}

}
); if (showContainer) { return ( -
+ `} + > {spinnerElement}
); @@ -70,7 +71,7 @@ export function LoadingSpinner({ } // Convenience components for common use cases -export function PageLoadingSpinner({ text = "Loading..." }: { text?: string }) { +export function PageLoadingSpinner({ text = 'Loading...' }: { text?: string }) { return (
@@ -78,6 +79,12 @@ export function PageLoadingSpinner({ text = "Loading..." }: { text?: string }) { ); } -export function InlineLoadingSpinner({ className = "", variant = "neutral" }: { className?: string; variant?: 'default' | 'primary' | 'neutral' }) { +export function InlineLoadingSpinner({ + className = '', + variant = 'neutral', +}: { + className?: string; + variant?: 'default' | 'primary' | 'neutral'; +}) { return ; -} \ No newline at end of file +} diff --git a/src/components/ui/Spinner.tsx b/apps/v1/client/src/components/ui/Spinner.tsx similarity index 61% rename from src/components/ui/Spinner.tsx rename to apps/v1/client/src/components/ui/Spinner.tsx index d696b356..13f90516 100644 --- a/src/components/ui/Spinner.tsx +++ b/apps/v1/client/src/components/ui/Spinner.tsx @@ -9,29 +9,22 @@ export const Spinner: React.FC = ({ size = 'small', className = '' const sizeClasses = { small: 'w-4 h-4', medium: 'w-6 h-6', - large: 'w-8 h-8' + large: 'w-8 h-8', }; return ( - - - + ); -}; \ No newline at end of file +}; diff --git a/src/components/ui/Toggle.tsx b/apps/v1/client/src/components/ui/Toggle.tsx similarity index 63% rename from src/components/ui/Toggle.tsx rename to apps/v1/client/src/components/ui/Toggle.tsx index 25eaee8b..078ed676 100644 --- a/src/components/ui/Toggle.tsx +++ b/apps/v1/client/src/components/ui/Toggle.tsx @@ -8,7 +8,13 @@ interface ToggleProps { className?: string; } -export function Toggle({ checked, onChange, label, disabled = false, className = '' }: ToggleProps) { +export function Toggle({ + checked, + onChange, + label, + disabled = false, + className = '', +}: ToggleProps) { const { currentStyles } = useTheme(); return ( @@ -24,9 +30,13 @@ export function Toggle({ checked, onChange, label, disabled = false, className = disabled={disabled} className="relative inline-block w-10 h-6 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" > -
-
+
+
); -} \ No newline at end of file +} diff --git a/src/components/ui/ToggleButton.tsx b/apps/v1/client/src/components/ui/ToggleButton.tsx similarity index 69% rename from src/components/ui/ToggleButton.tsx rename to apps/v1/client/src/components/ui/ToggleButton.tsx index cdb363d3..23d5c84d 100644 --- a/src/components/ui/ToggleButton.tsx +++ b/apps/v1/client/src/components/ui/ToggleButton.tsx @@ -8,7 +8,13 @@ interface ToggleButtonProps { className?: string; } -export function ToggleButton({ checked, onChange, label, disabled = false, className = '' }: ToggleButtonProps) { +export function ToggleButton({ + checked, + onChange, + label, + disabled = false, + className = '', +}: ToggleButtonProps) { const { currentStyles } = useTheme(); const styles = currentStyles; @@ -18,20 +24,22 @@ export function ToggleButton({ checked, onChange, label, disabled = false, class onClick={() => onChange(!checked)} disabled={disabled} className={`px-4 py-2 ${styles.borderRadius} transition-colors flex items-center gap-2 ${ - checked - ? `${styles.primaryButton} ${styles.primaryButtonText}` + checked + ? `${styles.primaryButton} ${styles.primaryButtonText}` : `bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600` } ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`} > -
+
{checked && ( - @@ -40,4 +48,4 @@ export function ToggleButton({ checked, onChange, label, disabled = false, class {label} ); -} \ No newline at end of file +} diff --git a/src/config/api.ts b/apps/v1/client/src/config/api.ts similarity index 99% rename from src/config/api.ts rename to apps/v1/client/src/config/api.ts index c620b51d..4bcbf376 100644 --- a/src/config/api.ts +++ b/apps/v1/client/src/config/api.ts @@ -20,4 +20,4 @@ export async function apiFetch(path: string, options?: RequestInit): Promise) => void; updatePersona: (id: string, updates: Partial) => void; deletePersona: (id: string) => void; createProject: (project: Omit) => void; updateProject: (id: string, updates: Partial) => void; - createWorkItem: (workItem: Omit) => WorkItem; + createWorkItem: ( + workItem: Omit + ) => WorkItem; updateWorkItem: (id: string, updates: Partial) => void; deleteWorkItem: (id: string) => void; assignPersonaToWorkItem: (workItemId: string, personaId: string) => void; startJamSession: (workItemId: string, participantIds: string[], title: string) => string; - addJamMessage: (sessionId: string, personaId: string, content: string, type: 'message' | 'challenge' | 'suggestion' | 'decision') => void; + addJamMessage: ( + sessionId: string, + personaId: string, + content: string, + type: 'message' | 'challenge' | 'suggestion' | 'decision' + ) => void; updateJamSession: (sessionId: string, updates: Partial) => void; syncWorkspaceProjects: (workspaceProjects: any[]) => void; } @@ -65,14 +72,16 @@ export function AppProvider({ children }: { children: ReactNode }) { }; const updatePersona = (id: string, updates: Partial) => { - setPersonas(personas.map(p => p.id === id ? { ...p, ...updates } : p)); + setPersonas(personas.map((p) => (p.id === id ? { ...p, ...updates } : p))); }; const deletePersona = (id: string) => { - setPersonas(personas.filter(p => p.id !== id)); + setPersonas(personas.filter((p) => p.id !== id)); }; - const createProject = (project: Omit) => { + const createProject = ( + project: Omit + ) => { const newProject: Project = { ...project, id: uuidv4(), @@ -84,14 +93,14 @@ export function AppProvider({ children }: { children: ReactNode }) { }; const updateProject = (id: string, updates: Partial) => { - setProjects(projects.map(p => - p.id === id - ? { ...p, ...updates, updatedAt: new Date() } - : p - )); + setProjects( + projects.map((p) => (p.id === id ? { ...p, ...updates, updatedAt: new Date() } : p)) + ); }; - const createWorkItem = (workItem: Omit) => { + const createWorkItem = ( + workItem: Omit + ) => { const newWorkItem: WorkItem = { ...workItem, id: uuidv4(), @@ -101,53 +110,58 @@ export function AppProvider({ children }: { children: ReactNode }) { metadata: workItem.metadata || undefined, }; setWorkItems([...workItems, newWorkItem]); - + // Add to project - setProjects(projects.map(p => - p.id === workItem.projectId - ? { ...p, workItems: [...p.workItems, newWorkItem.id], updatedAt: new Date() } - : p - )); - + setProjects( + projects.map((p) => + p.id === workItem.projectId + ? { ...p, workItems: [...p.workItems, newWorkItem.id], updatedAt: new Date() } + : p + ) + ); + return newWorkItem; }; const updateWorkItem = (id: string, updates: Partial) => { - setWorkItems(workItems.map(w => - w.id === id - ? { ...w, ...updates, updatedAt: new Date() } - : w - )); + setWorkItems( + workItems.map((w) => (w.id === id ? { ...w, ...updates, updatedAt: new Date() } : w)) + ); }; const deleteWorkItem = (id: string) => { // Remove from workItems - setWorkItems(workItems.filter(w => w.id !== id)); - + setWorkItems(workItems.filter((w) => w.id !== id)); + // Remove from project's workItems array - const workItem = workItems.find(w => w.id === id); + const workItem = workItems.find((w) => w.id === id); if (workItem) { - setProjects(projects.map(p => - p.id === workItem.projectId - ? { ...p, workItems: p.workItems.filter(wId => wId !== id), updatedAt: new Date() } - : p - )); + setProjects( + projects.map((p) => + p.id === workItem.projectId + ? { ...p, workItems: p.workItems.filter((wId) => wId !== id), updatedAt: new Date() } + : p + ) + ); } - + // Remove any jam sessions associated with this work item - setJamSessions(jamSessions.filter(js => js.workItemId !== id)); - + setJamSessions(jamSessions.filter((js) => js.workItemId !== id)); + // Clear any personas assigned to this work item - setPersonas(personas.map(p => - p.currentTaskId === id - ? { ...p, currentTaskId: undefined, status: 'available' } - : p - )); + setPersonas( + personas.map((p) => + p.currentTaskId === id ? { ...p, currentTaskId: undefined, status: 'available' } : p + ) + ); }; const assignPersonaToWorkItem = (workItemId: string, personaId: string) => { updateWorkItem(workItemId, { - assignedPersonaIds: [...(workItems.find(w => w.id === workItemId)?.assignedPersonaIds || []), personaId] + assignedPersonaIds: [ + ...(workItems.find((w) => w.id === workItemId)?.assignedPersonaIds || []), + personaId, + ], }); updatePersona(personaId, { currentTaskId: workItemId, status: 'busy' }); }; @@ -164,158 +178,205 @@ export function AppProvider({ children }: { children: ReactNode }) { status: 'active', }; setJamSessions([...jamSessions, newSession]); - + // Update work item - const workItem = workItems.find(w => w.id === workItemId); + const workItem = workItems.find((w) => w.id === workItemId); if (workItem) { updateWorkItem(workItemId, { - jamSessionIds: [...workItem.jamSessionIds, newSession.id] + jamSessionIds: [...workItem.jamSessionIds, newSession.id], }); } - + return newSession.id; }; - const addJamMessage = (sessionId: string, personaId: string, content: string, type: 'message' | 'challenge' | 'suggestion' | 'decision') => { - setJamSessions(jamSessions.map(session => - session.id === sessionId - ? { - ...session, - messages: [...session.messages, { - id: uuidv4(), - personaId, - content, - timestamp: new Date(), - type, - }] - } - : session - )); + const addJamMessage = ( + sessionId: string, + personaId: string, + content: string, + type: 'message' | 'challenge' | 'suggestion' | 'decision' + ) => { + setJamSessions( + jamSessions.map((session) => + session.id === sessionId + ? { + ...session, + messages: [ + ...session.messages, + { + id: uuidv4(), + personaId, + content, + timestamp: new Date(), + type, + }, + ], + } + : session + ) + ); }; - + const updateJamSession = (sessionId: string, updates: Partial) => { - setJamSessions(jamSessions.map(session => - session.id === sessionId - ? { ...session, ...updates } - : session - )); + setJamSessions( + jamSessions.map((session) => + session.id === sessionId ? { ...session, ...updates } : session + ) + ); }; const syncWorkspaceProjects = (workspaceProjects: any[]) => { console.log('Syncing workspace projects:', workspaceProjects); - console.log('Projects with plans:', workspaceProjects.filter(wp => wp.plans).map(wp => wp.name)); - console.log('Projects loading:', workspaceProjects.filter(wp => wp.isLoading).map(wp => wp.name)); - + console.log( + 'Projects with plans:', + workspaceProjects.filter((wp) => wp.plans).map((wp) => wp.name) + ); + console.log( + 'Projects loading:', + workspaceProjects.filter((wp) => wp.isLoading).map((wp) => wp.name) + ); + // First, sync work items from plans const allWorkItems: WorkItem[] = []; const projectWorkItemMap: { [projectId: string]: string[] } = {}; - - workspaceProjects.forEach(wp => { + + workspaceProjects.forEach((wp) => { // Skip projects that are still loading (don't have plans yet) if (wp.isLoading || !wp.plans) { - console.log(`Skipping project ${wp.name} - still loading details`, { isLoading: wp.isLoading, hasPlans: !!wp.plans }); + console.log(`Skipping project ${wp.name} - still loading details`, { + isLoading: wp.isLoading, + hasPlans: !!wp.plans, + }); return; } - + // Use the workspace path as a stable identifier (same as in project creation) const projectId = wp.path ? `project-${wp.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuidv4(); projectWorkItemMap[projectId] = []; - + // Process all plan types - ['ideas', 'planned', 'active', 'completed'].forEach(planType => { + ['ideas', 'planned', 'active', 'completed'].forEach((planType) => { if (wp.plans[planType]) { wp.plans[planType].forEach((plan: any) => { if (plan.workItem) { - // Use metadata workItemId if available, otherwise generate from plan name - const workItemId = plan.workItem.metadata?.workItemId || - `${projectId}-${plan.name.replace(/\.md$/, '')}`; - - // Check if work item already exists by ID or markdown path - const existingWorkItem = workItems.find(w => - w.id === workItemId || - (w.markdownPath && w.markdownPath === plan.path) - ); - - if (!existingWorkItem) { - console.log('Creating work item from plan:', { - planName: plan.name, - workItemTitle: plan.workItem.title, - tasksCount: plan.workItem.tasks ? plan.workItem.tasks.length : 0, - tasks: plan.workItem.tasks - }); - - const newWorkItem: WorkItem = { - id: workItemId, - title: plan.workItem.title || plan.name.replace(/\.md$/, '').replace(/-/g, ' '), - description: plan.workItem.description || '', - priority: (plan.workItem.priority || 'medium') as WorkItem['priority'], - status: plan.workItem.status === 'idea' ? 'todo' : - plan.workItem.status === 'planned' ? 'planned' : - plan.workItem.status === 'active' ? 'active' : - plan.workItem.status === 'completed' ? 'completed' : - planType === 'active' ? 'active' : - planType === 'completed' ? 'completed' : - planType === 'planned' ? 'planned' : 'todo', - projectId: projectId, - assignedPersonaIds: [], - workflow: [ - { name: 'Planning', status: planType === 'ideas' ? 'active' : 'completed' }, - { name: 'Development', status: planType === 'active' ? 'active' : planType === 'completed' ? 'completed' : 'pending' }, - { name: 'Testing', status: planType === 'completed' ? 'completed' : 'pending' }, - { name: 'Review', status: planType === 'completed' ? 'completed' : 'pending' } - ], - currentWorkflowStep: planType === 'ideas' ? 0 : planType === 'planned' ? 1 : planType === 'active' ? 2 : 3, - createdAt: new Date(), - updatedAt: new Date(), - jamSessionIds: [], - markdownPath: plan.path, - metadata: { - ...(plan.workItem.metadata || {}), - tasks: plan.workItem.tasks || [], - goals: plan.workItem.goals || [], - acceptanceCriteria: plan.workItem.acceptanceCriteria || [] - } - }; - allWorkItems.push(newWorkItem); - projectWorkItemMap[projectId].push(newWorkItem.id); - } + // Use metadata workItemId if available, otherwise generate from plan name + const workItemId = + plan.workItem.metadata?.workItemId || + `${projectId}-${plan.name.replace(/\.md$/, '')}`; + + // Check if work item already exists by ID or markdown path + const existingWorkItem = workItems.find( + (w) => w.id === workItemId || (w.markdownPath && w.markdownPath === plan.path) + ); + + if (!existingWorkItem) { + console.log('Creating work item from plan:', { + planName: plan.name, + workItemTitle: plan.workItem.title, + tasksCount: plan.workItem.tasks ? plan.workItem.tasks.length : 0, + tasks: plan.workItem.tasks, + }); + + const newWorkItem: WorkItem = { + id: workItemId, + title: plan.workItem.title || plan.name.replace(/\.md$/, '').replace(/-/g, ' '), + description: plan.workItem.description || '', + priority: (plan.workItem.priority || 'medium') as WorkItem['priority'], + status: + plan.workItem.status === 'idea' + ? 'todo' + : plan.workItem.status === 'planned' + ? 'planned' + : plan.workItem.status === 'active' + ? 'active' + : plan.workItem.status === 'completed' + ? 'completed' + : planType === 'active' + ? 'active' + : planType === 'completed' + ? 'completed' + : planType === 'planned' + ? 'planned' + : 'todo', + projectId: projectId, + assignedPersonaIds: [], + workflow: [ + { name: 'Planning', status: planType === 'ideas' ? 'active' : 'completed' }, + { + name: 'Development', + status: + planType === 'active' + ? 'active' + : planType === 'completed' + ? 'completed' + : 'pending', + }, + { name: 'Testing', status: planType === 'completed' ? 'completed' : 'pending' }, + { name: 'Review', status: planType === 'completed' ? 'completed' : 'pending' }, + ], + currentWorkflowStep: + planType === 'ideas' + ? 0 + : planType === 'planned' + ? 1 + : planType === 'active' + ? 2 + : 3, + createdAt: new Date(), + updatedAt: new Date(), + jamSessionIds: [], + markdownPath: plan.path, + metadata: { + ...(plan.workItem.metadata || {}), + tasks: plan.workItem.tasks || [], + goals: plan.workItem.goals || [], + acceptanceCriteria: plan.workItem.acceptanceCriteria || [], + }, + }; + allWorkItems.push(newWorkItem); + projectWorkItemMap[projectId].push(newWorkItem.id); } + } }); } }); }); - + // Add new work items (avoiding duplicates) if (allWorkItems.length > 0) { - setWorkItems(prevWorkItems => { + setWorkItems((prevWorkItems) => { // Create a map of existing work items by their markdown path - const existingPaths = new Set(prevWorkItems.map(item => item.markdownPath).filter(Boolean)); - + const existingPaths = new Set( + prevWorkItems.map((item) => item.markdownPath).filter(Boolean) + ); + // Filter out work items that already exist - const newUniqueWorkItems = allWorkItems.filter(item => - !item.markdownPath || !existingPaths.has(item.markdownPath) + const newUniqueWorkItems = allWorkItems.filter( + (item) => !item.markdownPath || !existingPaths.has(item.markdownPath) ); - + console.log('Syncing work items:', { existingCount: prevWorkItems.length, newCount: allWorkItems.length, uniqueNewCount: newUniqueWorkItems.length, - duplicatesSkipped: allWorkItems.length - newUniqueWorkItems.length + duplicatesSkipped: allWorkItems.length - newUniqueWorkItems.length, }); - + return [...prevWorkItems, ...newUniqueWorkItems]; }); } - + // Convert workspace projects to app projects - const newProjects: Project[] = workspaceProjects.map(wp => { + const newProjects: Project[] = workspaceProjects.map((wp) => { // Use the workspace path as a stable identifier - const stableProjectId = wp.path ? `project-${wp.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuidv4(); - + const stableProjectId = wp.path + ? `project-${wp.path.replace(/[^a-zA-Z0-9]/g, '-')}` + : uuidv4(); + // Check if project already exists by path or name - const existingProject = projects.find(p => p.path === wp.path || p.name === wp.name); + const existingProject = projects.find((p) => p.path === wp.path || p.name === wp.name); const projectId = existingProject?.id || stableProjectId; - + if (existingProject) { // Update existing project with workspace data return { @@ -327,10 +388,12 @@ export function AppProvider({ children }: { children: ReactNode }) { primaryRepoUrl: wp.primaryRepoUrl, readme: wp.readme, path: wp.path, - workItems: [...new Set([...existingProject.workItems, ...(projectWorkItemMap[projectId] || [])])] // Avoid duplicate work item IDs + workItems: [ + ...new Set([...existingProject.workItems, ...(projectWorkItemMap[projectId] || [])]), + ], // Avoid duplicate work item IDs }; } - + // Create new project from workspace data return { id: projectId, @@ -347,7 +410,7 @@ export function AppProvider({ children }: { children: ReactNode }) { readme: wp.readme, }; }); - + setProjects(newProjects); }; @@ -381,4 +444,4 @@ export function useApp() { throw new Error('useApp must be used within AppProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/AuthContext.tsx b/apps/v1/client/src/contexts/AuthContext.tsx similarity index 86% rename from src/contexts/AuthContext.tsx rename to apps/v1/client/src/contexts/AuthContext.tsx index aa6b9c0c..8dee1b1a 100644 --- a/src/contexts/AuthContext.tsx +++ b/apps/v1/client/src/contexts/AuthContext.tsx @@ -9,7 +9,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [authState, setAuthState] = useState({ isAuthenticated: false, accounts: [], - activeAccountId: null + activeAccountId: null, }); const [isInitialized, setIsInitialized] = useState(false); @@ -18,16 +18,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const loadAndVerifyAuth = async () => { const savedState = localStorage.getItem(STORAGE_KEY); console.log('Loading auth state from localStorage:', savedState); - + if (savedState) { try { const parsed = JSON.parse(savedState); console.log('Parsed auth state:', parsed); - + // For development, skip verification and trust the stored state // In production, you'd want to verify tokens are still valid const skipVerification = true; // Toggle this for development - + if (skipVerification) { // Just restore the saved state setAuthState(parsed); @@ -39,9 +39,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await fetch('http://localhost:3000/api/auth/github/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ accountId: account.id }) + body: JSON.stringify({ accountId: account.id }), }); - + if (response.ok) { validAccounts.push(account); } else { @@ -51,14 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.log(`Account ${account.username} verification error:`, error); } } - + // Update state with only valid accounts setAuthState({ isAuthenticated: validAccounts.length > 0, accounts: validAccounts, - activeAccountId: validAccounts.find(a => a.id === parsed.activeAccountId) - ? parsed.activeAccountId - : (validAccounts[0]?.id || null) + activeAccountId: validAccounts.find((a) => a.id === parsed.activeAccountId) + ? parsed.activeAccountId + : validAccounts[0]?.id || null, }); } } catch (error) { @@ -67,7 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } setIsInitialized(true); }; - + loadAndVerifyAuth(); }, []); @@ -79,16 +79,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [authState, isInitialized]); - const activeAccount = authState.accounts.find( - account => account.id === authState.activeAccountId - ) || null; + const activeAccount = + authState.accounts.find((account) => account.id === authState.activeAccountId) || null; const signInWithGitHub = async () => { try { // Initiate GitHub OAuth flow const response = await fetch('http://localhost:3000/api/auth/github/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { @@ -108,7 +107,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { 'github-oauth', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes,resizable=yes` ); - + if (!popup) { throw new Error('Please allow popups for GitHub authentication'); } @@ -116,7 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Listen for OAuth callback const handleMessage = async (event: MessageEvent) => { console.log('Message received:', event.origin, event.data); - + if (event.origin !== window.location.origin) { console.log('Origin mismatch:', event.origin, 'vs', window.location.origin); return; @@ -130,17 +129,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { const tokenResponse = await fetch('http://localhost:3000/api/auth/github/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, state }) + body: JSON.stringify({ code, state }), }); if (tokenResponse.ok) { const accountData = await tokenResponse.json(); - + // Add or update account in state - setAuthState(prev => { - const existingIndex = prev.accounts.findIndex( - acc => acc.id === accountData.id - ); + setAuthState((prev) => { + const existingIndex = prev.accounts.findIndex((acc) => acc.id === accountData.id); let newAccounts; if (existingIndex >= 0) { @@ -155,7 +152,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { isAuthenticated: true, accounts: newAccounts, - activeAccountId: accountData.id + activeAccountId: accountData.id, }; }); } @@ -177,7 +174,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { window.removeEventListener('message', handleMessage); } }, 1000); - } catch (error) { console.error('GitHub sign in error:', error); throw error; @@ -188,20 +184,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { // Call backend to revoke token await fetch(`http://localhost:3000/api/auth/github/logout/${accountId}`, { - method: 'POST' + method: 'POST', }); // Remove account from state - setAuthState(prev => { - const newAccounts = prev.accounts.filter(acc => acc.id !== accountId); + setAuthState((prev) => { + const newAccounts = prev.accounts.filter((acc) => acc.id !== accountId); const wasActive = prev.activeAccountId === accountId; - + return { isAuthenticated: newAccounts.length > 0, accounts: newAccounts, - activeAccountId: wasActive && newAccounts.length > 0 - ? newAccounts[0].id - : prev.activeAccountId + activeAccountId: + wasActive && newAccounts.length > 0 ? newAccounts[0].id : prev.activeAccountId, }; }); } catch (error) { @@ -211,9 +206,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; const switchAccount = (accountId: string) => { - setAuthState(prev => ({ + setAuthState((prev) => ({ ...prev, - activeAccountId: accountId + activeAccountId: accountId, })); }; @@ -223,9 +218,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await fetch('http://localhost:3000/api/auth/github/accounts'); if (response.ok) { const accounts = await response.json(); - setAuthState(prev => ({ + setAuthState((prev) => ({ ...prev, - accounts + accounts, })); } } catch (error) { @@ -239,7 +234,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { signInWithGitHub, signOut, switchAccount, - refreshAccounts + refreshAccounts, }; return {children}; @@ -251,4 +246,4 @@ export function useAuth() { throw new Error('useAuth must be used within an AuthProvider'); } return context; -} \ No newline at end of file +} diff --git a/apps/v1/client/src/contexts/ClaudeCodeContext.tsx b/apps/v1/client/src/contexts/ClaudeCodeContext.tsx new file mode 100644 index 00000000..56e369d4 --- /dev/null +++ b/apps/v1/client/src/contexts/ClaudeCodeContext.tsx @@ -0,0 +1,1034 @@ +import { + createContext, + useContext, + useState, + useCallback, + useRef, + useEffect, + type ReactNode, +} from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { generateSuggestedResponses } from '../utils/suggestedResponses'; + +export type ClaudeMode = 'default' | 'plan' | 'auto-accept'; + +export interface ToolExecution { + name: string; + args: any; + result: any; + isSuccess: boolean; + executionTime?: number; + timestamp: string; + status: 'pending' | 'running' | 'complete' | 'error'; +} + +export interface ClaudeMessage { + id: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + timestamp: Date; + isStreaming?: boolean; + toolExecutions?: ToolExecution[]; + startTime?: Date; + tokenCount?: number; + suggestedResponses?: string[]; + isGreeting?: boolean; + isError?: boolean; + mode?: ClaudeMode; + // Tool-specific fields + name?: string; + args?: string; + status?: 'pending' | 'running' | 'complete' | 'error'; + executionTime?: number; +} + +export interface Todo { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority: 'high' | 'medium' | 'low'; + parentId?: string; + children?: Todo[]; +} + +interface ClaudeCodeContextType { + // State + messages: ClaudeMessage[]; + mode: ClaudeMode; + contextUsage: number; // Percentage 0-100 + isInitializing: boolean; + isConnected: boolean; + error: string | null; + sessionId: string | null; + reservedRepo: string | null; + isProcessing: boolean; + currentMessageId: string | null; + todos: Todo[]; + + // Actions + sendMessage: (content: string) => Promise; + setMode: (mode: ClaudeMode) => void; + initializeSession: (projectId: string, projectPath: string, repoName: string) => Promise; + clearMessages: () => void; + cancelMessage: () => void; +} + +const ClaudeCodeContext = createContext(undefined); + +const STORAGE_KEY = 'claudeCodeState'; + +export function ClaudeCodeProvider({ children }: { children: ReactNode }) { + const mountCountRef = useRef(0); + const isMountedRef = useRef(true); + + // Debug logging for mount/unmount tracking + useEffect(() => { + isMountedRef.current = true; + mountCountRef.current++; + const mountTime = Date.now(); + console.log( + `[ClaudeCodeProvider] MOUNTED #${mountCountRef.current} at ${new Date(mountTime).toISOString()}` + ); + + return () => { + isMountedRef.current = false; + const unmountTime = Date.now(); + const lifetimeMs = unmountTime - mountTime; + console.log( + `[ClaudeCodeProvider] UNMOUNTING #${mountCountRef.current} at ${new Date(unmountTime).toISOString()}, lifetime: ${lifetimeMs}ms` + ); + }; + }, []); + + const [messages, setMessages] = useState([]); + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem('claudeCodeMode'); + return (saved as ClaudeMode) || 'default'; + }); + const [contextUsage, setContextUsage] = useState(0); + const [isInitializing, setIsInitializing] = useState(true); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [reservedRepo, setReservedRepo] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [currentMessageId, setCurrentMessageId] = useState(null); + const [todos, setTodos] = useState([]); + + const eventSourceRef = useRef(null); + const streamingMessageContentRef = useRef>(new Map()); + const connectionIdRef = useRef(null); + const connectionTimeoutRef = useRef(null); + const lastConnectionAttemptRef = useRef(0); + const sessionInitializationRef = useRef | null>(null); + const isRestoringExistingSessionRef = useRef(false); + + // Persist mode changes + useEffect(() => { + localStorage.setItem('claudeCodeMode', mode); + }, [mode]); + + // Clean up on unmount + useEffect(() => { + return () => { + console.log(`[ClaudeCodeProvider] Component cleanup running for sessionId: ${sessionId}`); + if (eventSourceRef.current) { + console.log(`[ClaudeCodeProvider] Closing SSE connection from cleanup`); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [sessionId]); + + const setupSSEConnection = useCallback( + (sessionId: string, repoName?: string) => { + console.log(`[ClaudeCodeProvider] setupSSEConnection called for sessionId: ${sessionId}`); + // Prevent multiple connections + if (eventSourceRef.current && eventSourceRef.current.readyState !== EventSource.CLOSED) { + console.log('[ClaudeCodeProvider] SSE connection already exists, skipping setup'); + return; + } + + // Debounce rapid connection attempts (React StrictMode in dev) + const now = Date.now(); + if (now - lastConnectionAttemptRef.current < 100) { + console.log('Debouncing rapid SSE connection attempt'); + return; + } + lastConnectionAttemptRef.current = now; + + // Store repo name for error handling + if (repoName) { + setReservedRepo(repoName); + } + + // Clear any pending connection timeout + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + + // Generate a unique connection ID to track this connection + const connectionId = `${sessionId}-${Date.now()}-${Math.random()}`; + console.log('Setting up SSE connection:', connectionId); + + // Close any existing connection + if (eventSourceRef.current) { + console.log('Closing existing SSE connection:', connectionIdRef.current); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + // Store the new connection ID + connectionIdRef.current = connectionId; + + const eventSource = new EventSource( + `http://localhost:3000/api/claude/code/stream?sessionId=${sessionId}&connectionId=${encodeURIComponent(connectionId)}` + ); + + eventSource.onopen = () => { + console.log('SSE connection opened:', connectionId); + setIsConnected(true); + + // Clear the restoring flag after a short delay to allow existing messages to be processed + if (isRestoringExistingSessionRef.current) { + setTimeout(() => { + console.log('Clearing isRestoringExistingSessionRef flag'); + isRestoringExistingSessionRef.current = false; + }, 2000); // 2 seconds to ensure all existing messages are processed + } + }; + + eventSource.onmessage = (event) => { + console.log('SSE default message received:', event.data); + }; + + eventSource.onerror = (error) => { + console.error('SSE connection error:', connectionId, error); + console.error('Error event type:', error.type); + console.error('EventSource readyState:', eventSource.readyState); + + // Only close on actual errors, not on connection establishment + if (eventSource.readyState === EventSource.CLOSED) { + console.log('SSE connection was closed'); + setIsConnected(false); + + if (eventSourceRef.current === eventSource) { + eventSourceRef.current = null; + } + } else { + console.log('SSE connection error but not closing, readyState:', eventSource.readyState); + } + }; + + eventSource.addEventListener('message-start', (event) => { + const data = JSON.parse(event.data); + console.log('message-start event received:', data); + console.log('Is greeting:', data.isGreeting); + console.log( + 'isRestoringExistingSessionRef.current:', + isRestoringExistingSessionRef.current + ); + + // Check if this is an existing message being restored + const isExistingMessage = isRestoringExistingSessionRef.current; + console.log('isExistingMessage:', isExistingMessage); + + // Create the new message + const newMessage: ClaudeMessage = { + id: data.id, + role: 'assistant', + content: '', // Start with empty content + timestamp: new Date(), + startTime: new Date(), + isStreaming: !isExistingMessage, // Don't stream existing messages + isGreeting: data.isGreeting, + isError: data.isError, + mode: data.mode, + }; + + console.log( + 'Creating message with isStreaming:', + !isExistingMessage, + 'isExistingMessage:', + isExistingMessage + ); + + // Check if message already exists or if we recently created a greeting + setMessages((prev) => { + console.log('setMessages called in message-start, prev length:', prev.length); + console.log( + 'Previous messages:', + prev.map((m) => ({ id: m.id, role: m.role, content: m.content.substring(0, 50) })) + ); + + const existingMessage = prev.find((msg) => msg.id === data.id); + if (existingMessage) { + console.warn('Message already exists with ID:', data.id); + return prev; + } + + // Check for recent greeting messages (within 2 seconds) + if (data.isGreeting) { + const recentGreeting = prev.find( + (msg) => + msg.isGreeting && + msg.timestamp && + new Date().getTime() - msg.timestamp.getTime() < 2000 + ); + if (recentGreeting) { + console.warn('Recent greeting already exists, ignoring duplicate'); + return prev; + } + } + + // Find and replace any placeholder message (empty assistant message that's streaming) + const placeholderIndex = prev.findIndex( + (msg) => + msg.role === 'assistant' && + msg.content === '' && + msg.isStreaming === true && + !msg.isGreeting + ); + + if (placeholderIndex !== -1) { + console.log('Replacing placeholder message at index:', placeholderIndex); + const newMessages = [...prev]; + newMessages[placeholderIndex] = newMessage; + return newMessages; + } + + console.log('Creating new message:', newMessage); + const newMessages = [...prev, newMessage]; + console.log( + 'New messages array:', + newMessages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content.substring(0, 50), + })) + ); + return newMessages; + }); + + if (!data.isGreeting && !isExistingMessage) { + setIsProcessing(true); + setCurrentMessageId(data.id); + } + + // Initialize streaming content for this message + if (!isExistingMessage) { + streamingMessageContentRef.current.set(data.id, ''); + console.log('Initialized streaming content for new message:', data.id); + } else { + const existingContent = streamingMessageContentRef.current.get(data.id) || ''; + console.log( + 'Preserving streaming content for existing message:', + data.id, + existingContent.substring(0, 50) + ); + } + }); + + eventSource.addEventListener('message-chunk', (event) => { + console.log('message-chunk event received, data length:', event.data.length); + const data = JSON.parse(event.data); + console.log('Chunk for messageId:', data.messageId); + console.log('Chunk content:', data.chunk); + console.log('Chunk content length:', data.chunk?.length); + + // Get current content for this message + const currentContent = streamingMessageContentRef.current.get(data.messageId) || ''; + + // For existing messages, reset the streaming content before adding the chunk + // This prevents appending to previous content + if (isRestoringExistingSessionRef.current) { + streamingMessageContentRef.current.set(data.messageId, data.chunk); + console.log('Restored existing message content, length:', data.chunk?.length); + } else { + // Append chunk to streaming content for new messages + streamingMessageContentRef.current.set(data.messageId, currentContent + data.chunk); + } + const updatedContent = streamingMessageContentRef.current.get(data.messageId) || ''; + console.log( + 'Streaming content for', + data.messageId, + 'now:', + updatedContent.substring(0, 50) + ); + + setMessages((prev) => { + console.log('setMessages in message-chunk, looking for messageId:', data.messageId); + console.log( + 'Current messages before update:', + prev.map((m) => ({ id: m.id, content: m.content.substring(0, 30) })) + ); + + const messageExists = prev.some((msg) => msg.id === data.messageId); + if (!messageExists) { + console.error('Message not found for chunk! MessageId:', data.messageId); + console.log('Attempting to create message from chunk data'); + // Create the message if it doesn't exist (can happen due to race conditions) + const newMessage: ClaudeMessage = { + id: data.messageId, + role: 'assistant', + content: streamingMessageContentRef.current.get(data.messageId) || '', + timestamp: new Date(), + startTime: new Date(), + isStreaming: true, + isGreeting: true, // Assume greeting for now + }; + return [...prev, newMessage]; + } + + const updated = prev.map((msg) => { + if (msg.id === data.messageId) { + const newContent = streamingMessageContentRef.current.get(data.messageId) || ''; + console.log( + 'Updating message:', + msg.id, + 'old content:', + msg.content.substring(0, 30), + 'new content:', + newContent.substring(0, 30) + ); + // For existing messages that aren't streaming, mark as complete immediately + const isComplete = !msg.isStreaming; + const updatedMessage = { + ...msg, + content: streamingMessageContentRef.current.get(data.messageId) || '', + isStreaming: isComplete ? false : msg.isStreaming, + }; + console.log('Updated message content length:', updatedMessage.content.length); + return updatedMessage; + } + return msg; + }); + console.log( + 'Updated messages after chunk:', + updated.map((m) => ({ id: m.id, content: m.content.substring(0, 30) })) + ); + return updated; + }); + }); + + eventSource.addEventListener('message-end', (event) => { + const data = JSON.parse(event.data); + console.log('message-end event received for messageId:', data.messageId); + setMessages((prev) => + prev.map((msg) => { + if (msg.id === data.messageId) { + console.log('Setting isStreaming to false for message:', msg.id); + // Generate suggested responses based on the final content + const suggestedResponses = generateSuggestedResponses(msg.content); + return { ...msg, isStreaming: false, suggestedResponses }; + } + return msg; + }) + ); + // Clean up streaming content for this message + streamingMessageContentRef.current.delete(data.messageId); + setIsProcessing(false); + setCurrentMessageId(null); + }); + + eventSource.addEventListener('message-complete', (event) => { + const data = JSON.parse(event.data); + console.log('message-complete event received for messageId:', data.messageId); + setMessages((prev) => { + console.log( + 'Processing message-complete, current messages:', + prev.map((m) => ({ id: m.id, content: m.content.substring(0, 30) })) + ); + const updated = prev.map((msg) => { + if (msg.id === data.messageId) { + console.log( + 'Found message to complete:', + msg.id, + 'current content length:', + msg.content.length + ); + const streamingContent = streamingMessageContentRef.current.get(data.messageId) || ''; + console.log('streamingContent length:', streamingContent.length); + + // For existing messages, always use the message content (it should already be set) + // For new messages, use streaming content + let finalContent = msg.content; + if (msg.content.length === 0 && streamingContent.length > 0) { + finalContent = streamingContent; + console.log('Using streaming content for empty message'); + } else if (msg.content.length > 0) { + console.log('Using existing message content'); + } + + console.log('Final content length:', finalContent.length); + console.log('Final content preview:', finalContent.substring(0, 50)); + + // Generate suggested responses based on the final content + const suggestedResponses = generateSuggestedResponses(finalContent); + return { ...msg, content: finalContent, isStreaming: false, suggestedResponses }; + } + return msg; + }); + console.log( + 'After message-complete, updated messages:', + updated.map((m) => ({ id: m.id, content: m.content.substring(0, 30) })) + ); + return updated; + }); + + // Clean up streaming content for this message if we're not restoring + if (!isRestoringExistingSessionRef.current) { + streamingMessageContentRef.current.delete(data.messageId); + } + setCurrentMessageId(null); + setIsProcessing(false); + }); + + eventSource.addEventListener('thinking', (event) => { + const data = JSON.parse(event.data); + // Show thinking status in the assistant message that's about to come + console.log('Claude is thinking...', data.status); + }); + + eventSource.addEventListener('progress', (event) => { + const data = JSON.parse(event.data); + console.log('Progress event received (should not happen):', data); + // Don't update message content with progress status + // Only update token count if needed + if (data.tokenCount) { + setMessages((prev) => + prev.map((msg) => + msg.id === data.messageId ? { ...msg, tokenCount: data.tokenCount } : msg + ) + ); + } + }); + + eventSource.addEventListener('tool-start', (event) => { + const data = JSON.parse(event.data); + console.log('tool-start event received:', data); + + // First, end any currently streaming assistant message + const currentStreamingMessageId = currentMessageId; + if (currentStreamingMessageId) { + console.log('Ending streaming message due to tool start:', currentStreamingMessageId); + setMessages((prev) => + prev.map((msg) => { + if (msg.id === currentStreamingMessageId && msg.isStreaming) { + console.log('Setting isStreaming to false for message:', msg.id); + // Generate suggested responses based on the current content + const suggestedResponses = generateSuggestedResponses(msg.content); + return { ...msg, isStreaming: false, suggestedResponses }; + } + return msg; + }) + ); + // Clean up streaming content for this message + streamingMessageContentRef.current.delete(currentStreamingMessageId); + setIsProcessing(false); + setCurrentMessageId(null); + } + + // Create a tool message with running status + const toolMessageId = data.toolId || `tool-${Date.now()}`; + const toolMessage: ClaudeMessage = { + id: toolMessageId, + role: 'tool', + content: '', // Tool messages don't need content as they use specialized fields + timestamp: new Date(), + name: data.name, + args: data.args, + status: 'running', + executionTime: undefined, + }; + + setMessages((prev) => { + // Check if this tool message already exists + if (prev.some((msg) => msg.id === toolMessageId)) { + // Update existing tool message + return prev.map((msg) => (msg.id === toolMessageId ? { ...msg, ...toolMessage } : msg)); + } else { + // Add new tool message + return [...prev, toolMessage]; + } + }); + }); + + eventSource.addEventListener('tool-execution', (event) => { + const data = JSON.parse(event.data); + console.log('tool-execution event received:', data); + + // Update the tool message with completion status + const toolMessageId = data.toolExecution.id || `tool-${Date.now()}`; + const toolMessage: ClaudeMessage = { + id: toolMessageId, + role: 'tool', + content: '', // Tool messages don't need content as they use specialized fields + timestamp: new Date(), + name: data.toolExecution.name, + args: data.toolExecution.args, + status: data.toolExecution.status || 'complete', + executionTime: data.toolExecution.executionTime, + }; + + setMessages((prev) => { + // Check if this tool message already exists + if (prev.some((msg) => msg.id === toolMessageId)) { + // Update existing tool message + return prev.map((msg) => + msg.id === toolMessageId + ? { + ...msg, + ...toolMessage, + status: 'complete', + executionTime: data.toolExecution.executionTime, + } + : msg + ); + } else { + // Add new tool message if it doesn't exist (backwards compatibility) + return [...prev, toolMessage]; + } + }); + + // Handle TodoWrite tool specifically + if (data.toolExecution.name === 'TodoWrite' && data.toolExecution.args) { + try { + // Parse the args to get the todos + const argsData = + typeof data.toolExecution.args === 'string' + ? JSON.parse(data.toolExecution.args) + : data.toolExecution.args; + + if (argsData.todos && Array.isArray(argsData.todos)) { + console.log('Updating todos from TodoWrite:', argsData.todos); + setTodos(argsData.todos); + } + } catch (err) { + console.error('Error parsing TodoWrite args:', err); + } + } + }); + + eventSource.addEventListener('tool-summary', (event) => { + const data = JSON.parse(event.data); + console.log('tool-summary event received:', data); + + // Mark all recent tool messages as complete and update execution times + setMessages((prev) => { + // Find the most recent tool messages (those that come after the last user message) + const lastUserMessageIndex = prev.findLastIndex( + (msg: ClaudeMessage) => msg.role === 'user' + ); + const recentMessages = prev.slice(lastUserMessageIndex + 1); + const toolMessages = recentMessages.filter( + (msg) => msg.role === 'tool' && msg.status === 'running' + ); + + console.log( + 'Tool messages to mark complete:', + toolMessages.map((m) => m.id) + ); + + return prev.map((msg) => { + // Check if this is a tool message that needs updating + const toolMsg = toolMessages.find((tm) => tm.id === msg.id); + if (toolMsg) { + // Find corresponding tool execution data from summary + const toolData = data.toolExecutions?.find( + (t: any) => msg.id.includes(t.id) || t.name === msg.name + ); + + return { + ...msg, + status: 'complete', + // Preserve executionTime from tool data or keep existing + executionTime: toolData?.executionTime || msg.executionTime, + }; + } + return msg; + }); + }); + }); + + eventSource.addEventListener('error', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (data.error === 'Session not found') { + // Session no longer exists, clear it + console.log('Session not found, clearing local state'); + setSessionId(null); + setIsConnected(false); + setError('Session expired. Please start a new session.'); + // Clear localStorage for this repo + if (reservedRepo) { + localStorage.removeItem(`${STORAGE_KEY}-${reservedRepo}`); + } + // Close the EventSource to stop reconnection attempts + eventSource.close(); + if (eventSourceRef.current === eventSource) { + eventSourceRef.current = null; + } + } + } catch (e) { + console.error('Failed to parse error event data:', e); + } + }); + + eventSource.addEventListener('claude-message', (event) => { + const data = JSON.parse(event.data); + console.log('Claude message event:', data.messageType, data.content); + + // Handle different message types from Claude + if (data.messageType === 'thinking') { + setMessages((prev) => + prev.map((msg) => + msg.id === data.messageId && msg.isStreaming + ? { ...msg, content: 'Claude is thinking...' } + : msg + ) + ); + } + }); + + eventSource.addEventListener('token-update', (event) => { + const data = JSON.parse(event.data); + // Update the message with actual token count + setMessages((prev) => + prev.map((msg) => + msg.id === data.messageId ? { ...msg, tokenCount: data.outputTokens } : msg + ) + ); + console.log( + `Token usage - Input: ${data.inputTokens}, Output: ${data.outputTokens}, Total: ${data.totalTokens}, Session: ${data.sessionTotal}` + ); + }); + + eventSource.addEventListener('context-update', (event) => { + const data = JSON.parse(event.data); + setContextUsage(data.percentage); + }); + + eventSource.addEventListener('tool-status', (event) => { + const data = JSON.parse(event.data); + console.log('Tool status update:', data); + + setMessages((prev) => + prev.map((msg) => + msg.id === data.messageId + ? { + ...msg, + status: data.status, + executionTime: data.executionTime, + } + : msg + ) + ); + }); + + eventSource.addEventListener('message-cancelled', (event) => { + const data = JSON.parse(event.data); + setMessages((prev) => + prev.map((msg) => + msg.id === data.messageId ? { ...msg, content: 'Cancelled', isStreaming: false } : msg + ) + ); + setIsProcessing(false); + setCurrentMessageId(null); + }); + + eventSource.addEventListener('session-end', (event) => { + const data = JSON.parse(event.data); + console.log('Session ended event:', data); + // Session has ended, clean up everything + setSessionId(null); + setIsConnected(false); + setMessages([]); + setError(null); + // Clear localStorage for this repo + if (reservedRepo) { + localStorage.removeItem(`${STORAGE_KEY}-${reservedRepo}`); + } + // Close the EventSource to stop reconnection attempts + eventSource.close(); + if (eventSourceRef.current === eventSource) { + eventSourceRef.current = null; + } + }); + + eventSourceRef.current = eventSource; + }, + [reservedRepo] + ); + + const initializeSession = useCallback( + async (projectId: string, projectPath: string, repoName: string) => { + // Prevent duplicate initialization requests + if (sessionInitializationRef.current) { + console.log('Session initialization already in progress, waiting for completion'); + return sessionInitializationRef.current; + } + + const initPromise = (async () => { + setIsInitializing(true); + setError(null); + + try { + // Get user info from auth state if available + const authStateStr = localStorage.getItem('github_auth_state'); + let userName = ''; + let userEmail = ''; + + if (authStateStr) { + try { + const authState = JSON.parse(authStateStr); + const activeAccount = authState.accounts?.find( + (acc: any) => acc.id === authState.activeAccountId + ); + if (activeAccount) { + userName = activeAccount.username || ''; + userEmail = activeAccount.email || ''; + } + } catch (e) { + console.error('Failed to parse auth state:', e); + } + } + + const response = await fetch('http://localhost:3000/api/claude/code/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId, + projectPath, + repoName, + userName, + userEmail, + initialMode: mode, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to initialize session: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Session response:', data); + setSessionId(data.sessionId); + setReservedRepo(data.reservedRepo || repoName); + setContextUsage(data.contextUsage || 0); + + // Always start with empty messages and let SSE populate them + // For existing sessions, the server will send existing messages via SSE + // For new sessions, we start fresh + console.log( + data.new + ? 'New session created, starting fresh' + : 'Existing session, will receive messages via SSE' + ); + setMessages([]); + + // Set flag to track if we're restoring an existing session + isRestoringExistingSessionRef.current = !data.new; + + // Only clear localStorage for truly new sessions + if (data.new) { + localStorage.removeItem(`${STORAGE_KEY}-${repoName}`); + } + + // Set up SSE connection with a delay to ensure component is stable + if (!eventSourceRef.current || eventSourceRef.current.readyState === EventSource.CLOSED) { + console.log('[ClaudeCodeProvider] Scheduling SSE connection setup with delay'); + const sessionIdToConnect = data.sessionId; + const repoNameToConnect = repoName; + setTimeout(() => { + // Check if component is still mounted before setting up connection + if (sessionIdToConnect && isMountedRef.current && !eventSourceRef.current) { + console.log('[ClaudeCodeProvider] Setting up delayed SSE connection'); + setupSSEConnection(sessionIdToConnect, repoNameToConnect); + } else if (!isMountedRef.current) { + console.log('[ClaudeCodeProvider] Component unmounted, skipping SSE setup'); + } else if (eventSourceRef.current) { + console.log( + '[ClaudeCodeProvider] SSE connection already exists, skipping delayed setup' + ); + } else { + console.log('[ClaudeCodeProvider] No session ID, skipping SSE setup'); + } + }, 500); // 500ms delay to allow component to stabilize + } + + setIsInitializing(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to initialize session'); + setIsInitializing(false); + } finally { + sessionInitializationRef.current = null; + } + })(); + + sessionInitializationRef.current = initPromise; + return initPromise; + }, + [setupSSEConnection, mode] + ); + + const sendMessage = useCallback( + async (content: string) => { + if (!sessionId || !content.trim()) return; + + console.log('Sending message:', content); + + // Add user message immediately + const userMessage: ClaudeMessage = { + id: uuidv4(), + role: 'user', + content, + timestamp: new Date(), + mode, + }; + + // Add a placeholder assistant message with isStreaming true to show dancing bubbles + const placeholderMessage: ClaudeMessage = { + id: uuidv4(), + role: 'assistant', + content: '', // Empty content will trigger dancing bubbles in ClaudeMessage component + timestamp: new Date(), + isStreaming: true, + startTime: new Date(), + }; + + setMessages((prev) => [...prev, userMessage, placeholderMessage]); + setIsProcessing(true); + + try { + const response = await fetch('http://localhost:3000/api/claude/code/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId, + message: content, + mode, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send message: ${response.statusText}`); + } + + console.log('Message sent successfully'); + // Response will come through SSE + } catch (err) { + console.error('Error sending message:', err); + setError(err instanceof Error ? err.message : 'Failed to send message'); + + // Remove the placeholder message and add error message + setMessages((prev) => { + // Filter out the placeholder + const filtered = prev.filter( + (msg) => !(msg.role === 'assistant' && msg.content === '' && msg.isStreaming === true) + ); + + // Add error message + const errorMessage: ClaudeMessage = { + id: uuidv4(), + role: 'system', + content: `Error: ${err instanceof Error ? err.message : 'Failed to send message'}`, + timestamp: new Date(), + }; + + return [...filtered, errorMessage]; + }); + + setIsProcessing(false); + } + }, + [sessionId, mode] + ); + + const clearMessages = useCallback(() => { + setMessages([]); + setTodos([]); + // Clear from localStorage too + if (reservedRepo) { + localStorage.removeItem(`${STORAGE_KEY}-${reservedRepo}`); + } + }, [reservedRepo]); + + const cancelMessage = useCallback(async () => { + if (!sessionId || !currentMessageId) return; + + try { + // Send cancel request to server + await fetch('http://localhost:3000/api/claude/code/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, messageId: currentMessageId }), + }); + + // Update UI immediately + setMessages((prev) => + prev.map((msg) => + msg.id === currentMessageId ? { ...msg, content: 'Cancelled', isStreaming: false } : msg + ) + ); + setIsProcessing(false); + setCurrentMessageId(null); + } catch (err) { + console.error('Failed to cancel message:', err); + } + }, [sessionId, currentMessageId]); + + // Save messages periodically + useEffect(() => { + if (sessionId && messages.length > 0 && reservedRepo) { + localStorage.setItem(`${STORAGE_KEY}-${reservedRepo}`, JSON.stringify({ messages })); + } + }, [messages, sessionId, reservedRepo]); + + // Cleanup SSE connection on unmount (but keep session alive) + useEffect(() => { + return () => { + console.log('ClaudeCodeProvider cleanup effect running'); + // Close SSE connection + if (eventSourceRef.current) { + console.log('Closing SSE connection in cleanup'); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + // NOTE: We intentionally do NOT end the Claude Code session here + // The session should persist even when navigating away from the page + // Users can manually end sessions if needed through a UI action + }; + }, []); + + const value: ClaudeCodeContextType = { + messages, + mode, + contextUsage, + isInitializing, + isConnected, + error, + sessionId, + reservedRepo, + isProcessing, + currentMessageId, + todos, + sendMessage, + setMode, + initializeSession, + clearMessages, + cancelMessage, + }; + + return {children}; +} + +export function useClaudeCode() { + const context = useContext(ClaudeCodeContext); + if (!context) { + throw new Error('useClaudeCode must be used within ClaudeCodeProvider'); + } + return context; +} diff --git a/src/contexts/GitHubContext.tsx b/apps/v1/client/src/contexts/GitHubContext.tsx similarity index 83% rename from src/contexts/GitHubContext.tsx rename to apps/v1/client/src/contexts/GitHubContext.tsx index 21136706..bd2ed714 100644 --- a/src/contexts/GitHubContext.tsx +++ b/apps/v1/client/src/contexts/GitHubContext.tsx @@ -8,8 +8,23 @@ interface GitHubContextValue { createRepository: (options: any) => Promise; getRepository: (owner: string, repo: string) => Promise; searchRepositories: (query: string, options?: any) => Promise; - createFile: (owner: string, repo: string, path: string, content: string, message: string, branch?: string) => Promise; - updateFile: (owner: string, repo: string, path: string, content: string, message: string, sha: string, branch?: string) => Promise; + createFile: ( + owner: string, + repo: string, + path: string, + content: string, + message: string, + branch?: string + ) => Promise; + updateFile: ( + owner: string, + repo: string, + path: string, + content: string, + message: string, + sha: string, + branch?: string + ) => Promise; createPullRequest: (owner: string, repo: string, options: any) => Promise; isAuthenticated: boolean; } @@ -40,7 +55,7 @@ export function GitHubProvider({ children }: { children: ReactNode }) { const value: GitHubContextValue = { isAuthenticated: !!activeAccount, - + listRepositories: async (options) => { try { const accountId = ensureAuthenticated(); @@ -80,7 +95,15 @@ export function GitHubProvider({ children }: { children: ReactNode }) { createFile: async (owner, repo, path, content, message, branch) => { try { const accountId = ensureAuthenticated(); - return await githubService.createFile(accountId, owner, repo, path, content, message, branch); + return await githubService.createFile( + accountId, + owner, + repo, + path, + content, + message, + branch + ); } catch (error) { return handleApiError(error); } @@ -89,7 +112,16 @@ export function GitHubProvider({ children }: { children: ReactNode }) { updateFile: async (owner, repo, path, content, message, sha, branch) => { try { const accountId = ensureAuthenticated(); - return await githubService.updateFile(accountId, owner, repo, path, content, message, sha, branch); + return await githubService.updateFile( + accountId, + owner, + repo, + path, + content, + message, + sha, + branch + ); } catch (error) { return handleApiError(error); } @@ -102,7 +134,7 @@ export function GitHubProvider({ children }: { children: ReactNode }) { } catch (error) { return handleApiError(error); } - } + }, }; return {children}; @@ -114,4 +146,4 @@ export function useGitHub() { throw new Error('useGitHub must be used within a GitHubProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/LayoutContext.tsx b/apps/v1/client/src/contexts/LayoutContext.tsx similarity index 86% rename from src/contexts/LayoutContext.tsx rename to apps/v1/client/src/contexts/LayoutContext.tsx index 02f3d59c..61011677 100644 --- a/src/contexts/LayoutContext.tsx +++ b/apps/v1/client/src/contexts/LayoutContext.tsx @@ -21,12 +21,14 @@ export function LayoutProvider({ children }: { children: ReactNode }) { const [headerContent, setHeaderContent] = useState(null); return ( - + {children} ); @@ -38,4 +40,4 @@ export function useLayout() { throw new Error('useLayout must be used within LayoutProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/NewWorkItemContext.tsx b/apps/v1/client/src/contexts/NewWorkItemContext.tsx similarity index 95% rename from src/contexts/NewWorkItemContext.tsx rename to apps/v1/client/src/contexts/NewWorkItemContext.tsx index 3fd3d41d..f6e10493 100644 --- a/src/contexts/NewWorkItemContext.tsx +++ b/apps/v1/client/src/contexts/NewWorkItemContext.tsx @@ -15,13 +15,13 @@ interface NewWorkItemContextType { // Step management step: 'input' | 'review'; setStep: (step: 'input' | 'review') => void; - + // Form state ideaText: string; setIdeaText: (text: string) => void; savedIdea: string; setSavedIdea: (text: string) => void; - + // Tasks tasks: Task[]; setTasks: (tasks: Task[]) => void; @@ -29,17 +29,17 @@ interface NewWorkItemContextType { setSelectedTaskId: (id: string | null) => void; editedContent: string; setEditedContent: (content: string) => void; - + // General markdown for work item description generalMarkdown: string; setGeneralMarkdown: (markdown: string) => void; - + // Processing state isProcessing: boolean; setIsProcessing: (processing: boolean) => void; error: string | null; setError: (error: string | null) => void; - + // Utility functions resetToInput: () => void; } @@ -50,10 +50,10 @@ const STORAGE_KEY = 'newWorkItemState'; export function NewWorkItemProvider({ children }: { children: ReactNode }) { const [searchParams, setSearchParams] = useSearchParams(); - + // Get initial step from URL const stepFromUrl = searchParams.get('step') as 'input' | 'review' | null; - + // Load persisted state const loadPersistedState = () => { const saved = sessionStorage.getItem(STORAGE_KEY); @@ -66,25 +66,27 @@ export function NewWorkItemProvider({ children }: { children: ReactNode }) { } return null; }; - + const persistedState = loadPersistedState(); - + const [step, setStepState] = useState<'input' | 'review'>(stepFromUrl || 'input'); - + // Form state - initialize from persisted state if available const [ideaText, setIdeaText] = useState(persistedState?.ideaText || ''); const [savedIdea, setSavedIdea] = useState(persistedState?.savedIdea || ''); - + // Tasks state const [tasks, setTasks] = useState(persistedState?.tasks || []); - const [selectedTaskId, setSelectedTaskId] = useState(persistedState?.selectedTaskId || null); + const [selectedTaskId, setSelectedTaskId] = useState( + persistedState?.selectedTaskId || null + ); const [editedContent, setEditedContent] = useState(persistedState?.editedContent || ''); const [generalMarkdown, setGeneralMarkdown] = useState(persistedState?.generalMarkdown || ''); - + // Processing state const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); - + // Persist state on changes useEffect(() => { const stateToSave = { @@ -97,7 +99,7 @@ export function NewWorkItemProvider({ children }: { children: ReactNode }) { }; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); }, [ideaText, savedIdea, tasks, selectedTaskId, editedContent, generalMarkdown]); - + // Update URL when step changes const setStep = (newStep: 'input' | 'review') => { setStepState(newStep); @@ -114,12 +116,12 @@ export function NewWorkItemProvider({ children }: { children: ReactNode }) { } setSearchParams(searchParams); }; - + // Handle browser back/forward useEffect(() => { const urlStep = searchParams.get('step') as 'input' | 'review' | null; const currentStep = urlStep || 'input'; - + if (currentStep !== step) { setStepState(currentStep); // If going back to input, restore the saved idea @@ -128,14 +130,14 @@ export function NewWorkItemProvider({ children }: { children: ReactNode }) { } } }, [searchParams, step, savedIdea]); - + // Reset to input step with saved idea const resetToInput = () => { setStep('input'); setIdeaText(savedIdea); setError(null); }; - + const value: NewWorkItemContextType = { step, setStep, @@ -157,12 +159,8 @@ export function NewWorkItemProvider({ children }: { children: ReactNode }) { setError, resetToInput, }; - - return ( - - {children} - - ); + + return {children}; } export function useNewWorkItem() { @@ -171,4 +169,4 @@ export function useNewWorkItem() { throw new Error('useNewWorkItem must be used within NewWorkItemProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/SubscriptionContext.tsx b/apps/v1/client/src/contexts/SubscriptionContext.tsx similarity index 58% rename from src/contexts/SubscriptionContext.tsx rename to apps/v1/client/src/contexts/SubscriptionContext.tsx index 70fad0dc..b304679e 100644 --- a/src/contexts/SubscriptionContext.tsx +++ b/apps/v1/client/src/contexts/SubscriptionContext.tsx @@ -23,11 +23,11 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) { try { const data = JSON.parse(event.data); const key = data.id; // Resource ID like "repo-status:project1/repo-1" - + // Call all registered callbacks for this resource const callbackSet = callbacks.current.get(key); if (callbackSet) { - callbackSet.forEach(cb => { + callbackSet.forEach((cb) => { try { cb(data.payload); } catch (err) { @@ -47,46 +47,51 @@ export function SubscriptionProvider({ children }: { children: ReactNode }) { }; }, [eventSource]); - const subscribeToResource = useCallback((type: string, id: string, callback: SubscriptionCallback) => { - const key = `${type}:${id}`; - - // Add callback to the map - if (!callbacks.current.has(key)) { - callbacks.current.set(key, new Set()); - } - callbacks.current.get(key)!.add(callback); - - // If this is the first subscription to this resource, tell the server - if (!activeSubscriptions.current.has(key)) { - activeSubscriptions.current.add(key); - subscribe([key]).catch(err => { - console.error('Failed to subscribe to resource:', err); - }); - } - - // Return unsubscribe function - return () => { - const callbackSet = callbacks.current.get(key); - if (callbackSet) { - callbackSet.delete(callback); - - // If no more callbacks for this resource, unsubscribe from server - if (callbackSet.size === 0) { - callbacks.current.delete(key); - activeSubscriptions.current.delete(key); - unsubscribe([key]).catch(err => { - console.error('Failed to unsubscribe from resource:', err); - }); - } + const subscribeToResource = useCallback( + (type: string, id: string, callback: SubscriptionCallback) => { + const key = `${type}:${id}`; + + // Add callback to the map + if (!callbacks.current.has(key)) { + callbacks.current.set(key, new Set()); + } + callbacks.current.get(key)!.add(callback); + + // If this is the first subscription to this resource, tell the server + if (!activeSubscriptions.current.has(key)) { + activeSubscriptions.current.add(key); + subscribe([key]).catch((err) => { + console.error('Failed to subscribe to resource:', err); + }); } - }; - }, [subscribe, unsubscribe]); + + // Return unsubscribe function + return () => { + const callbackSet = callbacks.current.get(key); + if (callbackSet) { + callbackSet.delete(callback); + + // If no more callbacks for this resource, unsubscribe from server + if (callbackSet.size === 0) { + callbacks.current.delete(key); + activeSubscriptions.current.delete(key); + unsubscribe([key]).catch((err) => { + console.error('Failed to unsubscribe from resource:', err); + }); + } + } + }; + }, + [subscribe, unsubscribe] + ); return ( - + {children} ); @@ -98,4 +103,4 @@ export function useRealtimeSubscription() { throw new Error('useRealtimeSubscription must be used within SubscriptionProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/ThemeContext.tsx b/apps/v1/client/src/contexts/ThemeContext.tsx similarity index 92% rename from src/contexts/ThemeContext.tsx rename to apps/v1/client/src/contexts/ThemeContext.tsx index 4095ba47..6d7593fb 100644 --- a/src/contexts/ThemeContext.tsx +++ b/apps/v1/client/src/contexts/ThemeContext.tsx @@ -14,39 +14,39 @@ const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { const [currentThemeIndex, setCurrentThemeIndex] = useState(0); - + useEffect(() => { const savedThemeId = localStorage.getItem('selectedTheme'); if (savedThemeId) { - const index = themes.findIndex(t => t.id === savedThemeId); + const index = themes.findIndex((t) => t.id === savedThemeId); if (index !== -1) { setCurrentThemeIndex(index); } } }, []); - + const currentTheme = themes[currentThemeIndex]; - + const setTheme = (themeId: string) => { - const index = themes.findIndex(t => t.id === themeId); + const index = themes.findIndex((t) => t.id === themeId); if (index !== -1) { setCurrentThemeIndex(index); localStorage.setItem('selectedTheme', themeId); } }; - + const nextTheme = () => { const nextIndex = (currentThemeIndex + 1) % themes.length; setCurrentThemeIndex(nextIndex); localStorage.setItem('selectedTheme', themes[nextIndex].id); }; - + const previousTheme = () => { const prevIndex = currentThemeIndex === 0 ? themes.length - 1 : currentThemeIndex - 1; setCurrentThemeIndex(prevIndex); localStorage.setItem('selectedTheme', themes[prevIndex].id); }; - + return ( {children} @@ -60,4 +60,4 @@ export function useTheme() { throw new Error('useTheme must be used within a ThemeProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/ThemeContextV2.tsx b/apps/v1/client/src/contexts/ThemeContextV2.tsx similarity index 88% rename from src/contexts/ThemeContextV2.tsx rename to apps/v1/client/src/contexts/ThemeContextV2.tsx index 009c6d16..583e97c1 100644 --- a/src/contexts/ThemeContextV2.tsx +++ b/apps/v1/client/src/contexts/ThemeContextV2.tsx @@ -24,25 +24,25 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const [isDarkMode, setIsDarkMode] = useState(false); const [backgroundEffectEnabled, setBackgroundEffectEnabled] = useState(false); const [animationsEnabled, setAnimationsEnabled] = useState(true); - + useEffect(() => { // Load saved preferences const savedThemeId = localStorage.getItem('selectedTheme'); const savedDarkMode = localStorage.getItem('darkMode') === 'true'; const savedBackgroundEffect = localStorage.getItem('backgroundEffect') === 'true'; const savedAnimations = localStorage.getItem('animationsEnabled') !== 'false'; - + setIsDarkMode(savedDarkMode); setBackgroundEffectEnabled(savedBackgroundEffect); setAnimationsEnabled(savedAnimations); - + if (savedThemeId) { - const index = themesV2.findIndex(t => t.id === savedThemeId); + const index = themesV2.findIndex((t) => t.id === savedThemeId); if (index !== -1) { setCurrentThemeIndex(index); } } - + // Apply dark mode class to root element if (savedDarkMode) { document.documentElement.classList.add('dark'); @@ -50,68 +50,70 @@ export function ThemeProvider({ children }: { children: ReactNode }) { document.documentElement.classList.remove('dark'); } }, []); - + const currentTheme = themesV2[currentThemeIndex]; const currentStyles = isDarkMode ? currentTheme.dark : currentTheme.light; - + const setTheme = (themeId: string) => { - const index = themesV2.findIndex(t => t.id === themeId); + const index = themesV2.findIndex((t) => t.id === themeId); if (index !== -1) { setCurrentThemeIndex(index); localStorage.setItem('selectedTheme', themeId); } }; - + const nextTheme = () => { const nextIndex = (currentThemeIndex + 1) % themesV2.length; setCurrentThemeIndex(nextIndex); localStorage.setItem('selectedTheme', themesV2[nextIndex].id); }; - + const previousTheme = () => { const prevIndex = currentThemeIndex === 0 ? themesV2.length - 1 : currentThemeIndex - 1; setCurrentThemeIndex(prevIndex); localStorage.setItem('selectedTheme', themesV2[prevIndex].id); }; - + const toggleDarkMode = () => { const newDarkMode = !isDarkMode; setIsDarkMode(newDarkMode); localStorage.setItem('darkMode', String(newDarkMode)); - + if (newDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }; - + const toggleBackgroundEffect = () => { const newValue = !backgroundEffectEnabled; setBackgroundEffectEnabled(newValue); localStorage.setItem('backgroundEffect', String(newValue)); }; - + const toggleAnimations = () => { const newValue = !animationsEnabled; setAnimationsEnabled(newValue); localStorage.setItem('animationsEnabled', String(newValue)); }; - + return ( - + {children} ); @@ -123,4 +125,4 @@ export function useTheme() { throw new Error('useTheme must be used within a ThemeProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/contexts/ToastContext.tsx b/apps/v1/client/src/contexts/ToastContext.tsx similarity index 92% rename from src/contexts/ToastContext.tsx rename to apps/v1/client/src/contexts/ToastContext.tsx index 14606c79..f867a973 100644 --- a/src/contexts/ToastContext.tsx +++ b/apps/v1/client/src/contexts/ToastContext.tsx @@ -23,8 +23,8 @@ export function ToastProvider({ children }: { children: ReactNode }) { const showToast = (message: string, type: ToastType = 'info', duration: number = 0) => { const id = Date.now().toString(); const toast: Toast = { id, message, type, duration }; - - setToasts(prev => [...prev, toast]); + + setToasts((prev) => [...prev, toast]); // Disabled auto-dismiss - toasts must be manually dismissed // if (duration > 0) { @@ -35,7 +35,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { }; const removeToast = (id: string) => { - setToasts(prev => prev.filter(toast => toast.id !== id)); + setToasts((prev) => prev.filter((toast) => toast.id !== id)); }; return ( @@ -51,4 +51,4 @@ export const useToast = () => { throw new Error('useToast must be used within a ToastProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/src/contexts/WorkspaceContext.tsx b/apps/v1/client/src/contexts/WorkspaceContext.tsx similarity index 61% rename from src/contexts/WorkspaceContext.tsx rename to apps/v1/client/src/contexts/WorkspaceContext.tsx index 4e2cc971..2685ba6d 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/apps/v1/client/src/contexts/WorkspaceContext.tsx @@ -17,23 +17,46 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { const [workspace, setWorkspace] = useState({ config: null, isLoading: false, - error: null + error: null, }); const [projects, setProjects] = useState([]); - // Load workspace config from localStorage on mount + // Load workspace config from user profile on mount useEffect(() => { - const savedConfig = localStorage.getItem('workspaceConfig'); - if (savedConfig) { - const config = JSON.parse(savedConfig) as WorkspaceConfig; - setWorkspace(prev => ({ ...prev, config })); - loadWorkspaceData(config.path); - } + loadUserProfile(); }, []); + const loadUserProfile = async () => { + try { + const response = await fetch('http://localhost:3000/api/user-profile'); + if (response.ok) { + const profile = await response.json(); + if (profile.workspaceRoot) { + const config: WorkspaceConfig = { + path: profile.workspaceRoot, + name: profile.workspaceRoot.split('/').pop() || 'Workspace', + }; + setWorkspace((prev) => ({ ...prev, config })); + loadWorkspaceData(profile.workspaceRoot); + } + } else { + console.error('Failed to load user profile'); + } + } catch (error) { + console.error('Error loading user profile:', error); + // Fall back to localStorage if server is not available + const savedConfig = localStorage.getItem('workspaceConfig'); + if (savedConfig) { + const config = JSON.parse(savedConfig) as WorkspaceConfig; + setWorkspace((prev) => ({ ...prev, config })); + loadWorkspaceData(config.path); + } + } + }; + const loadWorkspaceData = async (path: string) => { - setWorkspace(prev => ({ ...prev, isLoading: true, error: null })); - + setWorkspace((prev) => ({ ...prev, isLoading: true, error: null })); + try { // First, load basic project info quickly with caching const lightData = await getCached( @@ -44,7 +67,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ workspacePath: path }) + body: JSON.stringify({ workspacePath: path }), }); if (!lightResponse.ok) { @@ -56,12 +79,12 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { }, { maxAge: 60 * 1000, // 1 minute - staleWhileRevalidate: 5 * 60 * 1000 // 5 minutes + staleWhileRevalidate: 5 * 60 * 1000, // 5 minutes } ); setProjects(lightData.projects || []); - setWorkspace(prev => ({ ...prev, isLoading: false })); + setWorkspace((prev) => ({ ...prev, isLoading: false })); // Then load full details in the background if (lightData.projects && lightData.projects.length > 0) { @@ -71,13 +94,16 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { const details = await getCached( `project-details:${project.path}`, async () => { - const detailResponse = await fetch('http://localhost:3000/api/workspace/project-details', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ projectPath: project.path }) - }); + const detailResponse = await fetch( + 'http://localhost:3000/api/workspace/project-details', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ projectPath: project.path }), + } + ); if (!detailResponse.ok) { throw new Error('Failed to load project details'); @@ -87,15 +113,17 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { }, { maxAge: 5 * 60 * 1000, // 5 minutes - staleWhileRevalidate: 30 * 60 * 1000 // 30 minutes + staleWhileRevalidate: 30 * 60 * 1000, // 30 minutes } ); // Update the specific project with full details console.log('Updating project with details:', project.name, details); - setProjects(prev => prev.map(p => - p.path === project.path ? { ...p, ...details, isLoading: false } : p - )); + setProjects((prev) => + prev.map((p) => + p.path === project.path ? { ...p, ...details, isLoading: false } : p + ) + ); } catch (err) { console.error(`Failed to load details for project ${project.name}:`, err); } @@ -106,7 +134,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { } } catch (error) { console.error('Failed to load workspace:', error); - + // If it's a connection error, try to create the workspace structure if (error instanceof TypeError && error.message.includes('fetch')) { // Server might not be running, use mock data @@ -122,8 +150,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { path: `${path}/projects/project-mgmt-ux/repos/project-mgmt-ux-1`, isAvailable: false, activeWorkItem: 'implement-workspace', - branch: 'feature/workspace-support' - } + branch: 'feature/workspace-support', + }, ], plans: { ideas: [], @@ -133,20 +161,21 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { name: 'implement-workspace', path: `${path}/projects/project-mgmt-ux/plans/active/implement-workspace.md`, status: 'active', - content: '# Implement Workspace Support\n\nAdd workspace management to Claude Flow.' - } + content: + '# Implement Workspace Support\n\nAdd workspace management to Claude Flow.', + }, ], - completed: [] - } - } + completed: [], + }, + }, ]; setProjects(mockProjects); - setWorkspace(prev => ({ ...prev, isLoading: false })); + setWorkspace((prev) => ({ ...prev, isLoading: false })); } else { - setWorkspace(prev => ({ - ...prev, - isLoading: false, - error: error instanceof Error ? error.message : 'Failed to load workspace' + setWorkspace((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load workspace', })); } } @@ -155,9 +184,9 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { const setWorkspacePath = async (path: string) => { const config: WorkspaceConfig = { path, - name: path.split('/').pop() || 'Workspace' + name: path.split('/').pop() || 'Workspace', }; - + // First, try to create workspace structure if it doesn't exist try { const response = await fetch('http://localhost:3000/api/workspace/create', { @@ -165,7 +194,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ workspacePath: path }) + body: JSON.stringify({ workspacePath: path }), }); if (!response.ok) { @@ -174,13 +203,30 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { } catch (error) { console.warn('Could not create workspace structure:', error); } - - // Save to localStorage + + // Save to user profile + try { + const profileResponse = await fetch('http://localhost:3000/api/user-profile/workspace', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceRoot: path }), + }); + + if (!profileResponse.ok) { + console.error('Failed to update user profile:', await profileResponse.text()); + } + } catch (error) { + console.error('Error updating user profile:', error); + } + + // Also save to localStorage as fallback localStorage.setItem('workspaceConfig', JSON.stringify(config)); - + // Update state - setWorkspace(prev => ({ ...prev, config })); - + setWorkspace((prev) => ({ ...prev, config })); + // Load workspace data await loadWorkspaceData(path); }; @@ -199,20 +245,22 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { setWorkspace({ config: null, isLoading: false, - error: null + error: null, }); setProjects([]); }; return ( - + {children} ); @@ -224,4 +272,4 @@ export function useWorkspace() { throw new Error('useWorkspace must be used within a WorkspaceProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/hooks/useDraggable.ts b/apps/v1/client/src/hooks/useDraggable.ts similarity index 97% rename from src/hooks/useDraggable.ts rename to apps/v1/client/src/hooks/useDraggable.ts index be1c6e13..c07ad43b 100644 --- a/src/hooks/useDraggable.ts +++ b/apps/v1/client/src/hooks/useDraggable.ts @@ -19,15 +19,15 @@ export function useDraggable(options: UseDraggableOptions = {}) { useEffect(() => { const handleMouseDown = (e: MouseEvent) => { if (!handleRef.current?.contains(e.target as Node)) return; - + e.preventDefault(); setIsDragging(true); isDraggingRef.current = true; - + // Store initial positions dragStartPos.current = { x: e.clientX, y: e.clientY }; elementStartPos.current = { ...position }; - + // Add listeners to document to capture mouse movement outside element document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); @@ -35,31 +35,31 @@ export function useDraggable(options: UseDraggableOptions = {}) { const handleMouseMove = (e: MouseEvent) => { if (!isDraggingRef.current) return; - + const deltaX = e.clientX - dragStartPos.current.x; const deltaY = e.clientY - dragStartPos.current.y; - + const newX = elementStartPos.current.x + deltaX; const newY = elementStartPos.current.y + deltaY; - + setPosition({ x: newX, y: newY }); options.onDrag?.(newX, newY); }; const handleMouseUp = (e: MouseEvent) => { if (!isDraggingRef.current) return; - + setIsDragging(false); isDraggingRef.current = false; - + const deltaX = e.clientX - dragStartPos.current.x; const deltaY = e.clientY - dragStartPos.current.y; - + const finalX = elementStartPos.current.x + deltaX; const finalY = elementStartPos.current.y + deltaY; - + options.onDragEnd?.(finalX, finalY); - + // Remove document listeners document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); @@ -88,7 +88,7 @@ export function useDraggable(options: UseDraggableOptions = {}) { isDragging, style: { transform: `translate(${position.x}px, ${position.y}px)`, - transition: isDragging ? 'none' : 'transform 0.2s ease-out' - } + transition: isDragging ? 'none' : 'transform 0.2s ease-out', + }, }; -} \ No newline at end of file +} diff --git a/src/hooks/useFeedback.ts b/apps/v1/client/src/hooks/useFeedback.ts similarity index 50% rename from src/hooks/useFeedback.ts rename to apps/v1/client/src/hooks/useFeedback.ts index 635b645c..43d74083 100644 --- a/src/hooks/useFeedback.ts +++ b/apps/v1/client/src/hooks/useFeedback.ts @@ -26,7 +26,7 @@ export function useFeedback({ sessionId, repoName, projectId, - messageId + messageId, }: UseFeedbackOptions): UseFeedbackReturn { const { messages, mode, isConnected } = useClaudeCode(); const { showToast } = useToast(); @@ -47,7 +47,7 @@ export function useFeedback({ console.error('Failed to capture screenshot:', err); setCapturedScreenshot(null); } - + // Now open the dialog setShowDialog(true); setError(null); @@ -64,71 +64,80 @@ export function useFeedback({ setFeedbackId(null); }, []); - const submitFeedback = useCallback(async ( - expectedBehavior: string, - actualBehavior: string - ) => { - setIsSubmitting(true); - setError(null); + const submitFeedback = useCallback( + async (expectedBehavior: string, actualBehavior: string) => { + setIsSubmitting(true); + setError(null); - try { - // Prepare feedback data - const feedbackData: Omit = { - expectedBehavior, - actualBehavior, - sessionId, - repoName, - projectId, - messageId, - messages: messages.map(msg => ({ - id: msg.id, - role: msg.role, - content: msg.content, - timestamp: msg.timestamp, - isGreeting: msg.isGreeting, - toolExecutions: msg.toolExecutions - })), - mode, - isConnected - }; + try { + // Prepare feedback data + const feedbackData: Omit = { + expectedBehavior, + actualBehavior, + sessionId, + repoName, + projectId, + messageId, + messages: messages.map((msg) => ({ + id: msg.id, + role: msg.role, + content: msg.content, + timestamp: msg.timestamp, + isGreeting: msg.isGreeting, + toolExecutions: msg.toolExecutions, + })), + mode, + isConnected, + }; - // Upload the pre-captured screenshot if available - let screenshotPath: string | null = null; - if (capturedScreenshot) { - try { - screenshotPath = await feedbackService.uploadScreenshot( - capturedScreenshot, - sessionId, - repoName - ); - } catch (err) { - console.warn('Failed to upload screenshot:', err); + // Upload the pre-captured screenshot if available + let screenshotPath: string | null = null; + if (capturedScreenshot) { + try { + screenshotPath = await feedbackService.uploadScreenshot( + capturedScreenshot, + sessionId, + repoName + ); + } catch (err) { + console.warn('Failed to upload screenshot:', err); + } } - } - // Submit feedback with screenshot path - const completeData: FeedbackData = { - ...feedbackData, - screenshotPath: screenshotPath || undefined, - timestamp: new Date().toISOString() - }; - - const id = await feedbackService.submitFeedback(completeData); - - // Success! - setFeedbackId(id); - setShowDialog(false); - // Show toast notification instead of success dialog - showToast('Feedback submitted successfully!', 'success', 5000); - - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to submit feedback'; - setError(errorMessage); - console.error('Feedback submission failed:', err); - } finally { - setIsSubmitting(false); - } - }, [sessionId, repoName, projectId, messageId, messages, mode, isConnected, capturedScreenshot, showToast]); + // Submit feedback with screenshot path + const completeData: FeedbackData = { + ...feedbackData, + screenshotPath: screenshotPath || undefined, + timestamp: new Date().toISOString(), + }; + + const id = await feedbackService.submitFeedback(completeData); + + // Success! + setFeedbackId(id); + setShowDialog(false); + // Show toast notification instead of success dialog + showToast('Feedback submitted successfully!', 'success', 5000); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to submit feedback'; + setError(errorMessage); + console.error('Feedback submission failed:', err); + } finally { + setIsSubmitting(false); + } + }, + [ + sessionId, + repoName, + projectId, + messageId, + messages, + mode, + isConnected, + capturedScreenshot, + showToast, + ] + ); return { showDialog, @@ -139,6 +148,6 @@ export function useFeedback({ openFeedback, closeFeedback, submitFeedback, - closeSuccess + closeSuccess, }; -} \ No newline at end of file +} diff --git a/src/hooks/useNavigationDirection.ts b/apps/v1/client/src/hooks/useNavigationDirection.ts similarity index 96% rename from src/hooks/useNavigationDirection.ts rename to apps/v1/client/src/hooks/useNavigationDirection.ts index bea3b98e..70c88e61 100644 --- a/src/hooks/useNavigationDirection.ts +++ b/apps/v1/client/src/hooks/useNavigationDirection.ts @@ -14,15 +14,15 @@ export function useNavigationDirection(): NavigationDirection { useEffect(() => { const path = location.pathname; - + if (navigationType === 'POP') { // Browser back/forward navigation const stack = historyStack.current; const prevIndex = currentIndex.current; - + // Find where we are in the history - let newIndex = stack.lastIndexOf(path); - + const newIndex = stack.lastIndexOf(path); + if (newIndex === -1) { // Path not in history, treat as forward setDirection('forward'); @@ -40,7 +40,7 @@ export function useNavigationDirection(): NavigationDirection { } else { // Regular navigation (PUSH or REPLACE) setDirection('forward'); - + if (navigationType === 'PUSH') { // Trim any forward history and add new entry const stack = historyStack.current.slice(0, currentIndex.current + 1); @@ -64,4 +64,4 @@ export function useNavigationDirection(): NavigationDirection { }, [location, navigationType]); return direction; -} \ No newline at end of file +} diff --git a/src/hooks/useSubscription.ts b/apps/v1/client/src/hooks/useSubscription.ts similarity index 63% rename from src/hooks/useSubscription.ts rename to apps/v1/client/src/hooks/useSubscription.ts index 611b0f0b..7a5e4190 100644 --- a/src/hooks/useSubscription.ts +++ b/apps/v1/client/src/hooks/useSubscription.ts @@ -23,12 +23,12 @@ export function useSubscription(): UseSubscriptionReturn { const connect = () => { console.log('Connecting to SSE...'); es = new EventSource(sseUrl('/api/sse/subscribe')); - + es.onopen = () => { console.log('SSE connection opened'); reconnectAttemptsRef.current = 0; }; - + es.onmessage = (event) => { try { const data = JSON.parse(event.data); @@ -50,18 +50,18 @@ export function useSubscription(): UseSubscriptionReturn { setIsConnected(false); setClientId(null); } - + // Reconnect with exponential backoff const attempts = reconnectAttemptsRef.current; const delay = Math.min(5000 * Math.pow(2, attempts), 60000); // Max 60 seconds - + console.log(`Reconnecting in ${delay}ms (attempt ${attempts + 1})`); reconnectAttemptsRef.current++; - + if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } - + reconnectTimeoutRef.current = setTimeout(() => { if (isMounted && es?.readyState === EventSource.CLOSED) { connect(); @@ -87,63 +87,69 @@ export function useSubscription(): UseSubscriptionReturn { }; }, []); - const subscribe = useCallback(async (resources: string[]) => { - if (!clientId) { - console.warn('Cannot subscribe: no client ID'); - return; - } - - try { - const response = await fetch(apiUrl('/api/subscriptions'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId, resources }) - }); - - if (!response.ok) { - throw new Error('Failed to subscribe'); + const subscribe = useCallback( + async (resources: string[]) => { + if (!clientId) { + console.warn('Cannot subscribe: no client ID'); + return; } - - const result = await response.json(); - if (result.success) { - console.log('Subscribed to resources:', resources); + + try { + const response = await fetch(apiUrl('/api/subscriptions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId, resources }), + }); + + if (!response.ok) { + throw new Error('Failed to subscribe'); + } + + const result = await response.json(); + if (result.success) { + console.log('Subscribed to resources:', resources); + } + } catch (error) { + console.error('Error subscribing:', error); } - } catch (error) { - console.error('Error subscribing:', error); - } - }, [clientId]); - - const unsubscribe = useCallback(async (resources: string[]) => { - if (!clientId) { - console.warn('Cannot unsubscribe: no client ID'); - return; - } - - try { - const response = await fetch(apiUrl('/api/subscriptions'), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId, resources }) - }); - - if (!response.ok) { - throw new Error('Failed to unsubscribe'); + }, + [clientId] + ); + + const unsubscribe = useCallback( + async (resources: string[]) => { + if (!clientId) { + console.warn('Cannot unsubscribe: no client ID'); + return; } - - const result = await response.json(); - if (result.success) { - console.log('Unsubscribed from resources:', resources); + + try { + const response = await fetch(apiUrl('/api/subscriptions'), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId, resources }), + }); + + if (!response.ok) { + throw new Error('Failed to unsubscribe'); + } + + const result = await response.json(); + if (result.success) { + console.log('Unsubscribed from resources:', resources); + } + } catch (error) { + console.error('Error unsubscribing:', error); } - } catch (error) { - console.error('Error unsubscribing:', error); - } - }, [clientId]); + }, + [clientId] + ); return { eventSource, clientId, isConnected, subscribe, - unsubscribe + unsubscribe, }; -} \ No newline at end of file +} diff --git a/src/index.css b/apps/v1/client/src/index.css similarity index 92% rename from src/index.css rename to apps/v1/client/src/index.css index f75a877b..6ade8f4d 100644 --- a/src/index.css +++ b/apps/v1/client/src/index.css @@ -16,7 +16,7 @@ html { } /* Checkbox specific styles to prevent focus ring on click */ -input[type="checkbox"]:focus:not(:focus-visible) { +input[type='checkbox']:focus:not(:focus-visible) { box-shadow: none; outline: none; } @@ -80,33 +80,38 @@ body { .scrollbar-gutter-stable { scrollbar-gutter: stable; } - + .page-transition-enter { opacity: 0; transform: translateY(20px); } - + .page-transition-enter-active { opacity: 1; transform: translateY(0); - transition: opacity 300ms ease-out, transform 300ms ease-out; + transition: + opacity 300ms ease-out, + transform 300ms ease-out; } - + .page-transition-exit { opacity: 1; transform: translateY(0); } - + .page-transition-exit-active { opacity: 0; transform: translateY(-20px); - transition: opacity 150ms ease-in, transform 150ms ease-in; + transition: + opacity 150ms ease-in, + transform 150ms ease-in; } } /* Performance-optimized animations with more movement */ @keyframes float-slow { - 0%, 100% { + 0%, + 100% { transform: translate3d(0, 0, 0); } 25% { @@ -121,7 +126,8 @@ body { } @keyframes float-reverse { - 0%, 100% { + 0%, + 100% { transform: translate3d(0, 0, 0); } 25% { @@ -202,4 +208,4 @@ body { .animate-slide-up { animation: slide-up 0.3s ease-out; -} \ No newline at end of file +} diff --git a/src/main.tsx b/apps/v1/client/src/main.tsx similarity index 51% rename from src/main.tsx rename to apps/v1/client/src/main.tsx index c13d176f..f9316c37 100644 --- a/src/main.tsx +++ b/apps/v1/client/src/main.tsx @@ -1,12 +1,18 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; // Disable StrictMode in development to prevent double-invocation issues // that cause duplicate API requests, especially for Claude Code sessions const isDevelopment = import.meta.env.DEV; createRoot(document.getElementById('root')!).render( - isDevelopment ? : , -) + isDevelopment ? ( + + ) : ( + + + + ) +); diff --git a/src/pages/ClaudeCode.tsx b/apps/v1/client/src/pages/ClaudeCode.tsx similarity index 79% rename from src/pages/ClaudeCode.tsx rename to apps/v1/client/src/pages/ClaudeCode.tsx index b30e5f32..38a204c8 100644 --- a/src/pages/ClaudeCode.tsx +++ b/apps/v1/client/src/pages/ClaudeCode.tsx @@ -72,41 +72,52 @@ function ClaudeCodeContent() { setMode, initializeSession, cancelMessage, - clearMessages + clearMessages, } = useClaudeCode(); - + // Debug logging console.log('ClaudeCode component render, messages:', messages.length); - console.log('isInitializing:', isInitializing, 'isConnected:', isConnected, 'sessionId:', sessionId); - + console.log( + 'isInitializing:', + isInitializing, + 'isConnected:', + isConnected, + 'sessionId:', + sessionId + ); + const styles = currentStyles; const scrollContainerRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [showCloseConfirm, setShowCloseConfirm] = useState(false); const [showTodos, setShowTodos] = useState(true); const [repoStatus, setRepoStatus] = useState(null); - - const project = projects.find(p => p.id === projectId); - const workspaceProject = workspaceProjects.find(p => p.name === project?.name); - + + const project = projects.find((p) => p.id === projectId); + const workspaceProject = workspaceProjects.find((p) => p.name === project?.name); + // Subscribe to repo status updates useEffect(() => { if (!workspaceProject?.path || !repoName) return; - - const unsubscribe = subscribe('repo-status', `${workspaceProject.path}/${repoName}`, (status: RepoStatus) => { - setRepoStatus(status); - }); - + + const unsubscribe = subscribe( + 'repo-status', + `${workspaceProject.path}/${repoName}`, + (status: RepoStatus) => { + setRepoStatus(status); + } + ); + return unsubscribe; }, [workspaceProject?.path, repoName, subscribe]); - + // Re-show todos when new todos arrive useEffect(() => { if (todos.length > 0) { setShowTodos(true); } }, [todos.length]); - + // Set up session-level feedback const { showDialog: showSessionFeedback, @@ -114,96 +125,113 @@ function ClaudeCodeContent() { error: feedbackError, openFeedback: openSessionFeedback, closeFeedback: closeSessionFeedback, - submitFeedback: submitSessionFeedback + submitFeedback: submitSessionFeedback, } = useFeedback({ sessionId: sessionId || '', repoName: repoName || '', projectId: projectId || '', // No messageId for session-level feedback }); - + // Set breadcrumb immediately when data is available useEffect(() => { - console.log('ClaudeCode breadcrumb effect - repoName:', repoName, 'projectId:', projectId, 'project:', project); - + console.log( + 'ClaudeCode breadcrumb effect - repoName:', + repoName, + 'projectId:', + projectId, + 'project:', + project + ); + if (repoName && projectId && project) { console.log('Setting Claude Code breadcrumb with project:', project.name); const newBreadcrumb = [ { label: project.name, path: `/projects/${projectId}` }, { label: repoName }, - { label: 'Claude Code' } + { label: 'Claude Code' }, ]; console.log('New breadcrumb:', newBreadcrumb); setHeaderContent(newBreadcrumb); } - + // No cleanup needed - let the next page set its own header }, [projectId, repoName, project, setHeaderContent]); - + // Initialize session useEffect(() => { console.log('ClaudeCode init session:', { projectId, projectName: project?.name, workspaceProjectPath: workspaceProject?.path, - repoName + repoName, }); - + if (projectId && workspaceProject?.path && repoName) { initializeSession(projectId, workspaceProject.path, repoName); } }, [projectId, workspaceProject?.path, repoName]); - - const handleSubmit = useCallback(async (message: string) => { - if (!message.trim() || isSubmitting || !isConnected) return; - - // Check if user is approving a plan - if (mode === 'plan' && message.toLowerCase().includes('yes') && message.toLowerCase().includes('proceed')) { - // Switch to execution mode - setMode('default'); - } - - setIsSubmitting(true); - - try { - await sendMessage(message); - } catch (error) { - console.error('Failed to send message:', error); - } finally { - setIsSubmitting(false); - } - }, [isSubmitting, isConnected, sendMessage, mode, setMode]); - - const handleModeChange = useCallback((newMode: ClaudeMode) => { - setMode(newMode); - }, [setMode]); + + const handleSubmit = useCallback( + async (message: string) => { + if (!message.trim() || isSubmitting || !isConnected) return; + + // Check if user is approving a plan + if ( + mode === 'plan' && + message.toLowerCase().includes('yes') && + message.toLowerCase().includes('proceed') + ) { + // Switch to execution mode + setMode('default'); + } + + setIsSubmitting(true); + + try { + await sendMessage(message); + } catch (error) { + console.error('Failed to send message:', error); + } finally { + setIsSubmitting(false); + } + }, + [isSubmitting, isConnected, sendMessage, mode, setMode] + ); + + const handleModeChange = useCallback( + (newMode: ClaudeMode) => { + setMode(newMode); + }, + [setMode] + ); const handleCancel = useCallback(() => { setIsSubmitting(false); cancelMessage(); }, [cancelMessage]); - + const handleCloseSession = useCallback(() => { setShowCloseConfirm(true); }, []); - + const handleConfirmClose = useCallback(async () => { if (!sessionId) return; - + try { // End the Claude session const response = await fetch('http://localhost:3000/api/claude/code/end', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }) + body: JSON.stringify({ sessionId }), }); - + if (!response.ok) { const errorData = await response.json(); console.error('Failed to close session:', response.status, errorData); // Still navigate away even if session cleanup failed } - + // Clear messages and navigate back clearMessages(); navigate(`/projects/${projectId}`); @@ -214,23 +242,20 @@ function ClaudeCodeContent() { navigate(`/projects/${projectId}`); } }, [sessionId, projectId, navigate, clearMessages]); - + if (!project) { return (

Project not found

-
); } - + if (isInitializing) { return (
@@ -241,7 +266,7 @@ function ClaudeCodeContent() {
); } - + if (error) { return (
@@ -262,11 +287,13 @@ function ClaudeCodeContent() {
); } - + return (
{/* Button Bar */} -
+
{reservedRepo && ( <> @@ -278,13 +305,21 @@ function ClaudeCodeContent() { <>
Branch
-
{repoStatus.branch}
+
+ {repoStatus.branch} +
Changes
{repoStatus.isDirty ? ( - <>{repoStatus.changes.modified + repoStatus.changes.added + repoStatus.changes.deleted + repoStatus.changes.untracked} Unstaged + <> + {repoStatus.changes.modified + + repoStatus.changes.added + + repoStatus.changes.deleted + + repoStatus.changes.untracked}{' '} + Unstaged + ) : ( <>none )} @@ -296,37 +331,29 @@ function ClaudeCodeContent() { )}
- -
- + {/* Content Area - Split when todos exist */}
{/* Todo List - Shows when todos exist and user hasn't dismissed */} {todos.length > 0 && showTodos && ( -
- setShowTodos(false)} - /> +
+ setShowTodos(false)} />
)} - + {/* Messages Area */} -
) : ( - } onSuggestedResponse={(response) => { @@ -352,13 +379,13 @@ function ClaudeCodeContent() { )}
- + {/* Input Area or Progress Indicator */}
{isProcessing ? ( m.isStreaming)?.startTime || new Date()} - tokenCount={messages.find(m => m.isStreaming)?.tokenCount} + startTime={messages.find((m) => m.isStreaming)?.startTime || new Date()} + tokenCount={messages.find((m) => m.isStreaming)?.tokenCount} status="Processing" onCancel={handleCancel} /> @@ -373,7 +400,7 @@ function ClaudeCodeContent() { /> )}
- + {/* Confirm close dialog */} - + {/* Session feedback dialog */}
); -} \ No newline at end of file +} diff --git a/src/pages/Dashboard.tsx b/apps/v1/client/src/pages/Dashboard.tsx similarity index 61% rename from src/pages/Dashboard.tsx rename to apps/v1/client/src/pages/Dashboard.tsx index 7af002af..8b5377a7 100644 --- a/src/pages/Dashboard.tsx +++ b/apps/v1/client/src/pages/Dashboard.tsx @@ -6,28 +6,38 @@ import { Link } from 'react-router-dom'; export function Dashboard() { const { projects, workItems, personas, jamSessions } = useApp(); const { setHeaderContent } = useLayout(); - + // Clear header content on mount useEffect(() => { setHeaderContent(null); }, [setHeaderContent]); - - const activeProjects = projects.filter(p => p.status === 'active').length; - const activeWorkItems = workItems.filter(w => w.status === 'active').length; - const availablePersonas = personas.filter(p => p.status === 'available').length; - const activeJamSessions = jamSessions.filter(j => j.status === 'active').length; - + + const activeProjects = projects.filter((p) => p.status === 'active').length; + const activeWorkItems = workItems.filter((w) => w.status === 'active').length; + const availablePersonas = personas.filter((p) => p.status === 'available').length; + const activeJamSessions = jamSessions.filter((j) => j.status === 'active').length; + return (

Dashboard

- +
- - + +
@@ -48,13 +58,23 @@ export function Dashboard() {
- +
- - + +
@@ -75,13 +95,23 @@ export function Dashboard() {
- +
- - + +
@@ -102,18 +132,30 @@ export function Dashboard() {
- +
- - + +
-
Active Jam Sessions
+
+ Active Jam Sessions +
{activeJamSessions}
@@ -123,14 +165,17 @@ export function Dashboard() {
- + View sessions
- + {/* Quick Actions */}

Quick Actions

@@ -140,8 +185,18 @@ export function Dashboard() { className="relative rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm flex items-center space-x-3 hover:border-gray-400" >
- - + +
@@ -150,14 +205,24 @@ export function Dashboard() {

Start a new project

- +
- - + +
@@ -166,14 +231,24 @@ export function Dashboard() {

Add a new task

- +
- - + +
@@ -186,4 +261,4 @@ export function Dashboard() {
); -} \ No newline at end of file +} diff --git a/src/pages/DebugClaude.tsx b/apps/v1/client/src/pages/DebugClaude.tsx similarity index 81% rename from src/pages/DebugClaude.tsx rename to apps/v1/client/src/pages/DebugClaude.tsx index bc6e5d86..f5307bdf 100644 --- a/src/pages/DebugClaude.tsx +++ b/apps/v1/client/src/pages/DebugClaude.tsx @@ -101,45 +101,39 @@ export function DebugClaude() { } const data = await response.json(); - + // Update the response with actual data debugResponse.response = data; debugResponse.duration = Date.now() - startTime; - + // Update the responses array - setResponses(current => [ - { ...debugResponse }, - ...current.slice(1) - ]); - + setResponses((current) => [{ ...debugResponse }, ...current.slice(1)]); + // Switch to response tab if successful if (!data.error) { setActiveTab('response'); } - + setQuery(''); } catch (error) { console.error('Debug request failed:', error); - + // Update with error - debugResponse.response = { - error: error instanceof Error - ? `${error.message}. Make sure the server is running on http://localhost:3000` - : 'Unknown error' + debugResponse.response = { + error: + error instanceof Error + ? `${error.message}. Make sure the server is running on http://localhost:3000` + : 'Unknown error', }; debugResponse.duration = Date.now() - startTime; - + // Update the responses array - setResponses(current => [ - { ...debugResponse }, - ...current.slice(1) - ]); + setResponses((current) => [{ ...debugResponse }, ...current.slice(1)]); } finally { setLoading(false); } }; - return (
{/* Query Form */} @@ -147,18 +141,20 @@ export function DebugClaude() {

Claude Debug Interface

- + {mockMode ? 'Mock Mode' : 'Live Mode'} -
+
- +
- + +
+
- .btn-primary:hover { - background: var(--accent-hover); - } + +
+

Checkbox

+ +
+
+
+ + + +
+ Unchecked +
- .btn-secondary { - background: var(--bg-secondary); - color: var(--text-secondary); - border: 1px solid var(--border-default); - } +
+
+ + + +
+ Checked +
+
+
- .btn-secondary:hover { - background: var(--border-default); - } + +
+

Toggle

- .validation-demo { - margin-top: 20px; - } +
+
+
+
+
+ Off +
- @media (max-width: 768px) { - .container { - grid-template-columns: 1fr; - gap: 20px; - } - - .form-row { - flex-direction: column; - gap: 15px; - } - } - - - -
-

Input and Form Components

-
- 🌙 - Toggle Theme - ☀️ +
+
+
+
+ On +
+
-
-
- -
-

Interactive Demo

- - -
-

Text Input

- -
- -
- -
- - -
- -
- - -
Password must be at least 8 characters
-
- -
- -
+ +
+

Registration Form

+ +
+
+ + +
+
+ + +
- -
-

Textarea

- -
- -
+
+
+ + +
- -
-

Checkbox

- -
-
-
- - - -
- Unchecked -
- -
-
- - - -
- Checked -
-
+
+
+ + +
- -
-

Toggle

- -
-
-
-
-
- Off -
- -
-
-
-
- On -
-
+
+
+ + +
- -
-

Registration Form

- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- -
-
-
- - - -
- I agree to the terms and conditions -
-
- -
-
-
-
-
- Subscribe to newsletter -
-
- -
- - -
- +
+
+
+ + + +
+ I agree to the terms and conditions +
- -
-

Real-time Validation Demo

-
- - -
-
- -
- - -
+
+
+
+
+ Subscribe to newsletter +
+ +
+ + +
+
- -
-

Dark Theme Preview

- - -
-

Text Input

- -
- -
- -
- - -
- -
- - -
Password must be at least 8 characters
-
- -
- -
+ +
+

Real-time Validation Demo

+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

Dark Theme Preview

+ + +
+

Text Input

+ +
+ +
+ +
+ + +
+ +
+ + +
Password must be at least 8 characters
+
+ +
+ +
+
+ + +
+

Textarea

+ +
+ +
+
+ + +
+

Checkbox

+ +
+
+
+ + + +
+ Unchecked
- -
-

Textarea

- -
- -
+
+
+ + + +
+ Checked
+
+
- -
-

Checkbox

- -
-
-
- - - -
- Unchecked -
- -
-
- - - -
- Checked -
-
+ +
+

Toggle

+ +
+
+
+
+
+ Off
- -
-

Toggle

- -
-
-
-
-
- Off -
- -
-
-
-
- On -
-
+
+
+
+
+ On
+
+
- - \ No newline at end of file + + diff --git a/docs/plans/mockups/design-system/Modal.html b/docs/plans/mockups/design-system/Modal.html index cb8f86bc..62fa4e24 100644 --- a/docs/plans/mockups/design-system/Modal.html +++ b/docs/plans/mockups/design-system/Modal.html @@ -1,553 +1,556 @@ - + - - - + + + Modal and Dialog Components - - + +
-
-

Modal and Dialog Components

-
- 🌙 - Dark Mode -
-
- -
- - - - - - - - - - - +
+

Modal and Dialog Components

+
+ 🌙 + Dark Mode
+
+ +
+ + + + + + + + + + + +
@@ -555,263 +558,281 @@

Modal and Dialog Components