commit 24fec62d7fb399c08b1d49a56e71b28ee3625dc6 Author: Hongming Wang Date: Mon Apr 13 11:55:37 2026 -0700 initial commit — Molecule AI platform Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000..a6954b04 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,172 @@ +--- +name: code-review +description: "Review code for best practices, modularity, scalability, abstraction, test coverage, redundancy, hardcoded values, type safety, performance, naming, API design, async patterns, config/env sync, template consistency, and documentation alignment. Generates detailed report with issues and recommendations." +--- + +# Code Review + +Perform a comprehensive code review of recent changes or specified files to ensure quality standards. + +## Review Criteria + +### 1. Best Practices +- Follows TypeScript strict mode conventions +- Proper error handling (try/catch, error types, no silent failures) +- No hardcoded values (use environment variables or constants) +- Proper logging with appropriate log levels +- Security best practices (input validation, no SQL injection, XSS prevention) +- No console.log in production code (use logger) + +### 2. Modularity +- Single responsibility principle (each function/class does one thing) +- Functions are small and focused (< 50 lines ideally) +- No code duplication (DRY principle) +- Clear separation of concerns (routes, services, utilities) + +### 3. Scalability +- Efficient database queries (proper indexing, no N+1 queries) +- Connection pooling used correctly +- Async operations handled properly +- No blocking operations in hot paths + +### 4. Abstraction +- Interfaces/types defined for all public APIs +- Implementation details hidden behind abstractions +- Adapter pattern used for external services (LLM, database) +- Configuration externalized (not hardcoded) + +### 5. Test Coverage +- Unit tests exist for all utility functions and service functions +- Service layer has integration tests +- Edge cases are covered +- Test files go in `tests/unit/` or `tests/integration/`, named `*.test.ts` +- All exported functions have at least one test + +### 6. No Redundancy +- No duplicate code blocks (extract to shared functions/utilities) +- No repeated logic across files (consolidate into services) +- No redundant imports or unused variables +- No copy-pasted code with minor variations (use parameters/generics) +- No redundant API calls (cache or batch where appropriate) +- No repeated validation logic (create reusable validators) +- No duplicate helper logic in test files (extract shared test utilities) + +### 7. No Hardcoded Values +- No hardcoded URLs, API endpoints, or hostnames (use env vars) +- No hardcoded credentials, keys, or secrets (use env vars) +- No magic numbers without named constants +- No hardcoded file paths (use configuration or path utilities) +- No hardcoded timeouts/limits (externalize to config) +- No hardcoded error messages (use constants or i18n) +- No hardcoded feature flags (use configuration system) +- No hardcoded tenant/user IDs in business logic + +### 8. Type Safety +- No usage of `any` type (use `unknown` or proper types) +- Proper null/undefined handling (optional chaining, nullish coalescing) +- Generic types used appropriately +- Return types explicitly declared for public functions +- No type assertions (`as`) without validation + +### 9. Performance +- No memory leaks (cleanup subscriptions, timers, event listeners) +- Proper memoization for expensive computations +- Lazy loading for heavy components/modules +- Efficient data structures for the use case +- No synchronous operations blocking the event loop +- Batch API calls where possible (e.g., single `messages.modify` with multiple label IDs) + +### 10. Naming & Readability +- Descriptive variable/function names (no `x`, `temp`, `data`) +- Consistent naming conventions (camelCase, PascalCase) +- No misleading names (function does what name suggests) +- Boolean variables prefixed appropriately (`is`, `has`, `should`) +- No excessive abbreviations +- Code is self-documenting where possible + +### 11. API Design +- Consistent response formats across endpoints +- Proper HTTP status codes used +- Input validation at API boundaries +- Proper error response structure +- RESTful conventions followed +- API versioning considered for breaking changes + +### 12. Async & Concurrency +- No unhandled promise rejections +- Proper race condition handling +- Concurrent operations use Promise.all where appropriate +- No floating promises (missing await) +- Proper cleanup on component unmount/request abort +- AbortController used for cancellable operations + +### 13. Dependency Management +- No unused dependencies in package.json +- No deprecated packages +- Security vulnerabilities addressed (npm audit) +- Peer dependency conflicts resolved +- Dependencies pinned to specific versions where needed + +### 14. Environment & Configuration Sync +- Every env var used in `src/config/env.ts` is documented in `.env.example` +- Every env var in `.env.example` is defined in the Zod schema (`src/config/env.ts`) +- Default values match between `.env.example` comments and Zod `.default()` calls +- Conditional requirements are documented (e.g., "only required when LLM_PROVIDER=openai") +- No env vars referenced directly via `process.env` outside of `src/config/env.ts` and `src/lib/logger.ts` +- `docker-compose.yml` service ports/URLs align with `.env.example` defaults +- `Dockerfile` exposes the correct `PORT` matching `.env.example` +- `docs/railway-deployment.md` env var list matches the Zod schema + +### 15. Template & Documentation Consistency +- Email templates in `docs/templates/` have all `{{variable}}` placeholders documented in their "Available Variables" table +- Template variable sources match actual database columns and service outputs +- Classification categories in `docs/classification-design.md` match the `EmailCategory` type in `src/types/email.ts` +- Confidence thresholds in docs match the actual thresholds implemented in code +- Sub-types in docs match the template trigger conditions +- Gmail label names in code (`GmailLabel` const) match labels documented in architecture docs +- API endpoint schemas in `docs/api-spec.md` match actual route handler request/response types +- Error handling strategies in `docs/error-handling.md` match actual retry/error class behavior (e.g., `isRetryable` flags) + +### 16. Error Messages & UX +- User-friendly error messages (no technical jargon) +- Loading states for async operations +- Empty states handled gracefully +- Graceful degradation when features fail +- Confirmation for destructive actions +- Success feedback for completed actions +- Error boundaries to prevent full app crashes +- Proper form validation with clear feedback + +## Output Format + +```markdown +## Code Review Report + +### Files Reviewed +- List of files + +### Issues Found + +#### 🔴 Critical +- [file:line] Description - Recommendation + +#### 🟡 Warning +- [file:line] Description - Recommendation + +#### 🔵 Suggestions +- [file:line] Description - Recommendation + +### Config & Template Sync +- .env.example ↔ env.ts schema: [in sync / N mismatches] +- docs/classification-design.md ↔ src/types/email.ts: [in sync / N mismatches] +- docs/templates/ ↔ template variables: [in sync / N mismatches] +- docs/error-handling.md ↔ src/lib/errors.ts: [in sync / N mismatches] + +### Test Coverage +- Files missing tests +- Coverage gaps + +### Summary +- Total issues count +- Action items +``` diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md new file mode 100644 index 00000000..3870989b --- /dev/null +++ b/.agents/skills/update-docs/SKILL.md @@ -0,0 +1,60 @@ +--- +name: update-docs +description: "Review recent edits and update all documentation including architecture docs, API specs, and edit history. Creates missing docs for new implementations." +--- + +# Update Documentation + +Review recent code changes and update ALL relevant documentation in the `/docs` folder. + +## Steps + +1. **Read today's edit history** + + - Check `docs/edit-history/` for the current date's session file + - Identify all files that were modified + +2. **Analyze changes** + + - Read the modified files to understand what changed + - Categorize changes: new features, bug fixes, architecture changes, API changes, config changes + +3. **Update edit-history session file** + + - Add a summary section at the top describing what was accomplished + - Group related changes under descriptive headings + - Add any missing context about why changes were made + +4. **Update AGENTS.md if needed** + + - New commands or scripts added + - Architecture or key modules changed + - New environment variables required + - New routes or endpoints added + +5. **Update docs/README.md if needed** + + - New features or capabilities + - Changed setup instructions + - Updated project overview + +6. **Update docs/ files** + Review and update all architecture documentation to match current implementation + + **For each doc:** + + - Check if documented features match actual code implementation + - Update outdated sections to reflect current code + - Add NEW sections for features that are implemented but not documented + - Remove or mark deprecated features that no longer exist + - Ensure code examples match actual implementation + +7. **Create new docs if needed** + + - If a significant new feature or module was added but has no documentation, create appropriate documentation + - Follow existing documentation style and structure + +8. **Report summary** + - List all documentation files updated + - Note any new documentation files created + - Summarize key changes documented diff --git a/.claude/CLAUDE_LOOP_NOTES.md b/.claude/CLAUDE_LOOP_NOTES.md new file mode 100644 index 00000000..cdd4f7a9 --- /dev/null +++ b/.claude/CLAUDE_LOOP_NOTES.md @@ -0,0 +1,22 @@ +# Loop discipline — process notes + +## Rule: a "skipped" PR must have a comment explaining the skip + +When the hourly maintenance loop skips a PR for any reason — CI red, +conflicting, merge dirty, missing tests, design drift — the FIRST skip +in a session must leave a PR comment with the specific blocker and the +exact fix the author needs to apply. Subsequent skips of the same PR +(SHA unchanged) can be silent. + +The failure mode this rule prevents: silently skipping a PR for many +hours under a vague reason ("blocked / no CI / conflicting") without +ever telling the author what they need to do. The PR sits indefinitely +because the author has no comment to act on. + +Concrete check at the top of each loop: +- For every "known-blocked" PR I'm about to silently skip, verify there + is a bot/me comment on the PR newer than the PR's head SHA that names + the specific blocker. If not, that PR isn't actually blocked on the + author — it's blocked on me writing the comment. + +Caught 2026-04-13 on PR #114 (skipped 6+ loops with no comment). diff --git a/.claude/hooks/check-inbox.sh b/.claude/hooks/check-inbox.sh new file mode 100755 index 00000000..2c8cd2b8 --- /dev/null +++ b/.claude/hooks/check-inbox.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Check for unread agent messages in the bridge inbox +INBOX="/Users/hongming/Documents/GitHub/molecule-monorepo/.claude-bridge/inbox.jsonl" +if [ -f "$INBOX" ]; then + UNREAD=$(grep -c '"responded": false' "$INBOX" 2>/dev/null || echo 0) + if [ "$UNREAD" -gt 0 ]; then + echo "[INBOX] You have $UNREAD unread message(s) from agents. Run: cat .claude-bridge/inbox.jsonl" + fi +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..3f6a458f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,35 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "echo 'Reminder: Consider using /code-review or /update-docs'" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash /Users/hongming/Documents/GitHub/molecule-monorepo/.claude/hooks/check-inbox.sh" + } + ] + } + ] + }, + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["-y", "@anthropic/chrome-devtools-mcp"] + }, + "supabase": { + "url": "https://mcp.supabase.com/mcp?project_ref=jdxhoqdnxshzbjasfhfz" + } + } +} diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 00000000..a6954b04 --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,172 @@ +--- +name: code-review +description: "Review code for best practices, modularity, scalability, abstraction, test coverage, redundancy, hardcoded values, type safety, performance, naming, API design, async patterns, config/env sync, template consistency, and documentation alignment. Generates detailed report with issues and recommendations." +--- + +# Code Review + +Perform a comprehensive code review of recent changes or specified files to ensure quality standards. + +## Review Criteria + +### 1. Best Practices +- Follows TypeScript strict mode conventions +- Proper error handling (try/catch, error types, no silent failures) +- No hardcoded values (use environment variables or constants) +- Proper logging with appropriate log levels +- Security best practices (input validation, no SQL injection, XSS prevention) +- No console.log in production code (use logger) + +### 2. Modularity +- Single responsibility principle (each function/class does one thing) +- Functions are small and focused (< 50 lines ideally) +- No code duplication (DRY principle) +- Clear separation of concerns (routes, services, utilities) + +### 3. Scalability +- Efficient database queries (proper indexing, no N+1 queries) +- Connection pooling used correctly +- Async operations handled properly +- No blocking operations in hot paths + +### 4. Abstraction +- Interfaces/types defined for all public APIs +- Implementation details hidden behind abstractions +- Adapter pattern used for external services (LLM, database) +- Configuration externalized (not hardcoded) + +### 5. Test Coverage +- Unit tests exist for all utility functions and service functions +- Service layer has integration tests +- Edge cases are covered +- Test files go in `tests/unit/` or `tests/integration/`, named `*.test.ts` +- All exported functions have at least one test + +### 6. No Redundancy +- No duplicate code blocks (extract to shared functions/utilities) +- No repeated logic across files (consolidate into services) +- No redundant imports or unused variables +- No copy-pasted code with minor variations (use parameters/generics) +- No redundant API calls (cache or batch where appropriate) +- No repeated validation logic (create reusable validators) +- No duplicate helper logic in test files (extract shared test utilities) + +### 7. No Hardcoded Values +- No hardcoded URLs, API endpoints, or hostnames (use env vars) +- No hardcoded credentials, keys, or secrets (use env vars) +- No magic numbers without named constants +- No hardcoded file paths (use configuration or path utilities) +- No hardcoded timeouts/limits (externalize to config) +- No hardcoded error messages (use constants or i18n) +- No hardcoded feature flags (use configuration system) +- No hardcoded tenant/user IDs in business logic + +### 8. Type Safety +- No usage of `any` type (use `unknown` or proper types) +- Proper null/undefined handling (optional chaining, nullish coalescing) +- Generic types used appropriately +- Return types explicitly declared for public functions +- No type assertions (`as`) without validation + +### 9. Performance +- No memory leaks (cleanup subscriptions, timers, event listeners) +- Proper memoization for expensive computations +- Lazy loading for heavy components/modules +- Efficient data structures for the use case +- No synchronous operations blocking the event loop +- Batch API calls where possible (e.g., single `messages.modify` with multiple label IDs) + +### 10. Naming & Readability +- Descriptive variable/function names (no `x`, `temp`, `data`) +- Consistent naming conventions (camelCase, PascalCase) +- No misleading names (function does what name suggests) +- Boolean variables prefixed appropriately (`is`, `has`, `should`) +- No excessive abbreviations +- Code is self-documenting where possible + +### 11. API Design +- Consistent response formats across endpoints +- Proper HTTP status codes used +- Input validation at API boundaries +- Proper error response structure +- RESTful conventions followed +- API versioning considered for breaking changes + +### 12. Async & Concurrency +- No unhandled promise rejections +- Proper race condition handling +- Concurrent operations use Promise.all where appropriate +- No floating promises (missing await) +- Proper cleanup on component unmount/request abort +- AbortController used for cancellable operations + +### 13. Dependency Management +- No unused dependencies in package.json +- No deprecated packages +- Security vulnerabilities addressed (npm audit) +- Peer dependency conflicts resolved +- Dependencies pinned to specific versions where needed + +### 14. Environment & Configuration Sync +- Every env var used in `src/config/env.ts` is documented in `.env.example` +- Every env var in `.env.example` is defined in the Zod schema (`src/config/env.ts`) +- Default values match between `.env.example` comments and Zod `.default()` calls +- Conditional requirements are documented (e.g., "only required when LLM_PROVIDER=openai") +- No env vars referenced directly via `process.env` outside of `src/config/env.ts` and `src/lib/logger.ts` +- `docker-compose.yml` service ports/URLs align with `.env.example` defaults +- `Dockerfile` exposes the correct `PORT` matching `.env.example` +- `docs/railway-deployment.md` env var list matches the Zod schema + +### 15. Template & Documentation Consistency +- Email templates in `docs/templates/` have all `{{variable}}` placeholders documented in their "Available Variables" table +- Template variable sources match actual database columns and service outputs +- Classification categories in `docs/classification-design.md` match the `EmailCategory` type in `src/types/email.ts` +- Confidence thresholds in docs match the actual thresholds implemented in code +- Sub-types in docs match the template trigger conditions +- Gmail label names in code (`GmailLabel` const) match labels documented in architecture docs +- API endpoint schemas in `docs/api-spec.md` match actual route handler request/response types +- Error handling strategies in `docs/error-handling.md` match actual retry/error class behavior (e.g., `isRetryable` flags) + +### 16. Error Messages & UX +- User-friendly error messages (no technical jargon) +- Loading states for async operations +- Empty states handled gracefully +- Graceful degradation when features fail +- Confirmation for destructive actions +- Success feedback for completed actions +- Error boundaries to prevent full app crashes +- Proper form validation with clear feedback + +## Output Format + +```markdown +## Code Review Report + +### Files Reviewed +- List of files + +### Issues Found + +#### 🔴 Critical +- [file:line] Description - Recommendation + +#### 🟡 Warning +- [file:line] Description - Recommendation + +#### 🔵 Suggestions +- [file:line] Description - Recommendation + +### Config & Template Sync +- .env.example ↔ env.ts schema: [in sync / N mismatches] +- docs/classification-design.md ↔ src/types/email.ts: [in sync / N mismatches] +- docs/templates/ ↔ template variables: [in sync / N mismatches] +- docs/error-handling.md ↔ src/lib/errors.ts: [in sync / N mismatches] + +### Test Coverage +- Files missing tests +- Coverage gaps + +### Summary +- Total issues count +- Action items +``` diff --git a/.claude/skills/seo-audit b/.claude/skills/seo-audit new file mode 120000 index 00000000..e89ca8bc --- /dev/null +++ b/.claude/skills/seo-audit @@ -0,0 +1 @@ +../../.agents/skills/seo-audit \ No newline at end of file diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md new file mode 100644 index 00000000..459b89f9 --- /dev/null +++ b/.claude/skills/update-docs/SKILL.md @@ -0,0 +1,89 @@ +--- +name: update-docs +description: "Review recent edits and update all documentation including architecture docs, API specs, and edit history. Creates missing docs for new implementations." +--- + +# Update Documentation + +Review recent code changes and update ALL relevant documentation in the `/docs` folder. + +## Steps + +1. **Read today's edit history** + + - Check `docs/edit-history/` for the current date's session file + - Identify all files that were modified + +2. **Analyze changes** + + - Read the modified files to understand what changed + - Categorize changes: new features, bug fixes, architecture changes, API changes, config changes + +3. **Update edit-history session file** + + - Add a summary section at the top describing what was accomplished + - Group related changes under descriptive headings + - Add any missing context about why changes were made + +4. **Update CLAUDE.md if needed** + + - New commands or scripts added + - Architecture or key modules changed + - New environment variables required + - New routes or endpoints added + - Test counts when new test files were added + +5. **Update PLAN.md (repo root) if needed** + + - When a planned phase ships, mark it complete and add any follow-ups + - When new architectural decisions are made, update the relevant phase + - Keep the current status / next steps section in sync with reality + - If a feature was reverted, document the reversal and reasoning + +6. **Update README.md (repo root) if needed** + + - New features visible to users (canvas tabs, deploy flows, etc.) + - Changed setup or quickstart instructions + - Updated tech stack list (when adding/removing major dependencies) + - Updated test counts in the status badges + - License or branding changes + +7. **Update README.zh-CN.md (repo root) if README.md was updated** + + - Mirror any user-visible changes from README.md + - Keep the Chinese translation in sync — don't let it drift + - Update the same sections in both files (status, features, setup, license) + +8. **Update .env.example (repo root) if needed** + + - Every new env var read by code must be documented in `.env.example` + - Include a comment describing the var and its expected format + - When removing an env var from code, remove from `.env.example` + - Keep default values consistent with code defaults + +9. **Update docs/README.md if needed** + + - New features or capabilities + - Changed setup instructions + - Updated project overview + +10. **Update docs/ files** + Review and update all architecture documentation to match current implementation + + **For each doc:** + + - Check if documented features match actual code implementation + - Update outdated sections to reflect current code + - Add NEW sections for features that are implemented but not documented + - Remove or mark deprecated features that no longer exist + - Ensure code examples match actual implementation + +11. **Create new docs if needed** + + - If a significant new feature or module was added but has no documentation, create appropriate documentation + - Follow existing documentation style and structure + +12. **Report summary** + - List all documentation files updated + - Note any new documentation files created + - Summarize key changes documented diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b6e3a863 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# Postgres +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB=molecule +DATABASE_URL=postgres://USER:PASS@postgres:5432/molecule?sslmode=disable + +# Redis +REDIS_URL=redis://redis:6379 + +# Platform +PORT=8080 +SECRETS_ENCRYPTION_KEY= # 32-byte key (raw or base64). Leave empty for plaintext (dev only). +CONFIGS_DIR= # Path to workspace-configs-templates/ (auto-discovered if empty) +PLUGINS_DIR= # Path to plugins/ directory (default: /plugins in container) + +# Plugin install safeguards (POST /workspaces/:id/plugins) +# All three bound the cost of a single install so a slow/malicious +# source can't tie up a handler. Defaults are sane for typical use. +PLUGIN_INSTALL_BODY_MAX_BYTES=65536 # max request body size (default: 64 KiB) +PLUGIN_INSTALL_FETCH_TIMEOUT=5m # duration string; whole fetch+copy deadline +PLUGIN_INSTALL_MAX_DIR_BYTES=104857600 # max staged-tree size (default: 100 MiB) + +# Phase 30.7 — remote-agent liveness threshold. Workspaces with +# runtime='external' are marked offline if their last_heartbeat_at is +# older than this many seconds. Slightly larger than the 60s Redis TTL +# so transient WAN hiccups don't flap online/offline. Set to 0 to use +# the built-in default (90s). +REMOTE_LIVENESS_STALE_AFTER=90 + +# Canvas +NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 +NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws + +# Workspace Runtime +ANTHROPIC_API_KEY= +OPENROUTER_API_KEY= # OpenRouter API key (openrouter.ai). Use with model: openrouter:anthropic/claude-3.5-haiku +GROQ_API_KEY= # Groq API key (console.groq.com). Use with model: groq:llama-3.3-70b-versatile +CEREBRAS_API_KEY= # Cerebras API key (cloud.cerebras.ai). Use with model: cerebras:llama3.1-8b +GOOGLE_API_KEY= # Google AI API key (aistudio.google.com). Use with model: google_genai:gemini-2.5-flash +MAX_TOKENS=2048 # Max output tokens for OpenRouter requests (default: 2048) +LANGGRAPH_RECURSION_LIMIT=500 # LangGraph/DeepAgents max ReAct steps per turn (lib default: 25; raised to 500 — PM fan-out to 6+ reports + synthesis routinely exceeds 100) +MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false — anything strconv.ParseBool recognises). Triggers A2A proxy to rewrite 127.0.0.1: agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails (e.g. Podman, custom runtimes) or to force off. +MODEL_PROVIDER=anthropic:claude-sonnet-4-6 # Format: provider:model. Providers: anthropic, openai, openrouter, groq, cerebras, google_genai, ollama + +# Social Channels (optional — configure per-workspace via API or Canvas) +TELEGRAM_BOT_TOKEN= # Telegram Bot API token (talk to @BotFather). Used as default for new Telegram channels. + +# Langfuse (optional observability) +LANGFUSE_HOST=http://langfuse-web:3000 +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= + +# ---- Operator identity (for org-templates/reno-stars/, see OPERATOR_NOTES.md) ---- +# These are NOT consumed by the platform itself — they're documented here so +# operators of the reno-stars template (and any future operator-personalised +# template) know what to set as global_secrets. The platform injects every +# global_secret into every workspace container as an env var; the agent +# system-prompts reference them via ${VAR_NAME}. +OPERATOR_EMAIL= # e.g. you@example.com +OPERATOR_PHONE= # e.g. 555-123-4567 (display only, not used for SMS) +OPERATOR_TELEGRAM_ID= # numeric Telegram user ID (for bot DMs) +GADS_MCC_ID= # Google Ads MCC (manager) account ID, format 123-456-7890 +GADS_CUSTOMER_ID= # Google Ads child customer ID, format 987-654-3210 +GCP_PROJECT_ID= # Google Cloud project ID (e.g. my-website-123456) +GSC_SERVICE_ACCOUNT= # Search Console reporter service account email diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a3772a93 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Shell scripts must stay LF so they execute inside Linux containers +# when cloned on Windows with core.autocrlf=true. +*.sh text eol=lf +workspace-template/entrypoint.sh text eol=lf + +# Dockerfiles and compose files are parsed by tools that tolerate CRLF, +# but keep LF for consistency across platforms. +Dockerfile text eol=lf +*.dockerfile text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..fe37fbe1 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Pre-commit hook — enforces Starfire codebase conventions. +# Install: git config core.hooksPath .githooks +# +# Checks run ONLY on staged files to keep commits fast. +# If any check fails, the commit is rejected — the agent must fix it. + +set -euo pipefail + +ERRORS=0 + +# ────────────────────────────────────────────────────────── +# 1. Canvas: 'use client' directive on hook-using components +# ────────────────────────────────────────────────────────── + +STAGED_TSX=$(git diff --cached --name-only --diff-filter=ACM | grep '\.tsx$' | grep 'canvas/src/' || true) + +if [ -n "$STAGED_TSX" ]; then + for f in $STAGED_TSX; do + # Skip test files + if echo "$f" | grep -q "__tests__\|\.test\."; then + continue + fi + # Check if file uses hooks/handlers + if grep -qE "useState|useEffect|useCallback|useMemo|useRef|useStore|onClick|onChange" "$f" 2>/dev/null; then + # Check if 'use client' is in the first 3 lines + if ! head -3 "$f" | grep -qE "use client" 2>/dev/null; then + echo "❌ MISSING 'use client': $f" + echo " This file uses React hooks but lacks the 'use client' directive." + echo " Add \"'use client';\" as the very first line." + ERRORS=$((ERRORS + 1)) + fi + fi + done +fi + +# ────────────────────────────────────────────────────────── +# 2. Canvas: No light theme colors in new/changed components +# ────────────────────────────────────────────────────────── + +if [ -n "$STAGED_TSX" ]; then + for f in $STAGED_TSX; do + # Check staged diff (not full file) for white/light colors + DIFF=$(git diff --cached "$f" | grep '^+' | grep -v '^+++' || true) + if echo "$DIFF" | grep -qiE 'background:\s*#fff|background:\s*white|bg-white|bg-gray-[12]00' 2>/dev/null; then + echo "⚠️ LIGHT THEME COLOR in $f — use zinc-900/950 backgrounds, not white/gray" + ERRORS=$((ERRORS + 1)) + fi + done +fi + +STAGED_CSS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.css$' | grep 'canvas/src/' || true) +if [ -n "$STAGED_CSS" ]; then + for f in $STAGED_CSS; do + DIFF=$(git diff --cached "$f" | grep '^+' | grep -v '^+++' || true) + if echo "$DIFF" | grep -qiE 'background:\s*#fff|background:\s*white' 2>/dev/null; then + echo "⚠️ LIGHT THEME COLOR in $f — use zinc-900 (#18181b), not white" + ERRORS=$((ERRORS + 1)) + fi + done +fi + +# ────────────────────────────────────────────────────────── +# 3. Python: No bare except pass (silent swallowing) +# ────────────────────────────────────────────────────────── + +STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' | grep 'workspace-template/' || true) + +if [ -n "$STAGED_PY" ]; then + for f in $STAGED_PY; do + DIFF=$(git diff --cached "$f" | grep '^+' | grep -v '^+++' || true) + if echo "$DIFF" | grep -qE '^\+\s*except.*:\s*$' 2>/dev/null; then + NEXT_LINE=$(echo "$DIFF" | grep -A1 '^\+\s*except.*:\s*$' | tail -1) + if echo "$NEXT_LINE" | grep -qE '^\+\s*pass\s*$' 2>/dev/null; then + echo "⚠️ SILENT EXCEPTION SWALLOW in $f — add logger.debug() instead of bare 'pass'" + fi + fi + done +fi + +# ────────────────────────────────────────────────────────── +# 4. Go: No string-concatenated SQL +# ────────────────────────────────────────────────────────── + +STAGED_GO=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' | grep 'platform/' || true) + +if [ -n "$STAGED_GO" ]; then + for f in $STAGED_GO; do + DIFF=$(git diff --cached "$f" | grep '^+' | grep -v '^+++' || true) + if echo "$DIFF" | grep -qE 'fmt\.Sprintf.*SELECT|fmt\.Sprintf.*INSERT|fmt\.Sprintf.*UPDATE|fmt\.Sprintf.*DELETE' 2>/dev/null; then + echo "❌ SQL INJECTION RISK in $f — use parameterized queries (\$1, \$2), not fmt.Sprintf" + ERRORS=$((ERRORS + 1)) + fi + done +fi + +# ────────────────────────────────────────────────────────── +# 5. Secrets: No tokens/keys in staged files +# ────────────────────────────────────────────────────────── + +ALL_STAGED=$(git diff --cached --name-only --diff-filter=ACM || true) +if [ -n "$ALL_STAGED" ]; then + for f in $ALL_STAGED; do + # Skip binary, known safe files, hooks, docs, and markdown + if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/|\.md$|docs/'; then + continue + fi + DIFF=$(git diff --cached "$f" 2>/dev/null | grep '^+' | grep -v '^+++' || true) + if echo "$DIFF" | grep -qE 'sk-ant-|sk-proj-|ghp_|gho_|AKIA[A-Z0-9]' 2>/dev/null; then + echo "❌ POSSIBLE SECRET in $f — do not commit API keys or tokens" + ERRORS=$((ERRORS + 1)) + fi + done +fi + +# ────────────────────────────────────────────────────────── +# Result +# ────────────────────────────────────────────────────────── + +if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "🚫 Pre-commit check failed with $ERRORS error(s). Fix them and try again." + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d04db9e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + platform-build: + name: Platform (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: platform + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - run: go mod download + - run: go build ./cmd/server + - run: go build -o molecli ./cmd/cli + - run: go vet ./... + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + working-directory: platform + args: --timeout 3m + continue-on-error: true # Warn but don't block until codebase is clean + - name: Run tests with race detection and coverage + run: go test -race -coverprofile=coverage.out ./... + - name: Check coverage baseline + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Total coverage: ${COVERAGE}%" + THRESHOLD=25 + awk "BEGIN{if ($COVERAGE < $THRESHOLD) exit 1}" || { + echo "::error::Coverage ${COVERAGE}% is below the ${THRESHOLD}% threshold" + exit 1 + } + + canvas-build: + name: Canvas (Next.js) + runs-on: ubuntu-latest + defaults: + run: + working-directory: canvas + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: rm -f package-lock.json && npm install + - run: npm run build + - name: Run tests + run: npx vitest run + + mcp-server-build: + name: MCP Server (Node.js) + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp-server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: mcp-server/package-lock.json + - run: npm ci + - run: npm run build + + python-lint: + name: Python Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: workspace-template + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: workspace-template/requirements.txt + - run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov + - run: python -m pytest --tb=short -q --cov=. --cov-report=term-missing + + # Lint first-party plugins. The validator checks each plugin + # against the format it declares — currently agentskills.io for all + # of ours, but the same command covers any future shape that lands + # under a sibling adapter (MCP, DeepAgents sub-agent, etc.). + - name: Install molecule-plugin SDK + working-directory: sdk/python + run: pip install -e . + - name: Lint first-party plugins + working-directory: ${{ github.workspace }} + run: python -m molecule_plugin validate plugins/molecule-dev plugins/superpowers plugins/ecc + - name: Run SDK tests + working-directory: sdk/python + run: python -m pytest --tb=short -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f1ec7587 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Binaries +platform/server +platform/molecli +*.exe +*.out +*.bin + +# Go +*.test + +# Dependencies +node_modules/ + +# Build output +dist/ +**/.next/ +canvas/tsconfig.tsbuildinfo +canvas/next-env.d.ts +mcp-server/dist/ + +# Environment & secrets +.env +.env.local +.env.*.local +.env.production + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ + +# Docker +*.log + +# Local docker-compose overrides (per-developer port remaps, etc.) +docker-compose.override.yml +docker-compose.override.yaml + +# Test / coverage +coverage/ +.coverage +.coverage.* +.nyc_output/ +test-results/ +playwright-report/ + +# Databases (local dev) +*.db +*.sqlite +*.sqlite3 + +# Langfuse / ClickHouse / Docker volumes +langfuse_data/ +clickhouse_data/ +postgres_data/ +redis_data/ + +# Auth tokens +.auth-token + +# Awareness memory (local agent memory, not project code) +.awareness/ + +# Claude Code worktrees and runtime artifacts +.claude/worktrees/ +.claude/scheduled_tasks.lock + +# Workspace instance configs (auto-generated by provisioner, not templates) +workspace-configs-templates/ws-* + +# Workspace runtime markers (written by agent containers, not committed) +.initial_prompt_done + +# Exported bundles (may contain env vars / secrets) +*.bundle.json + +# Logs +logs/ + +# Backups +backups/ +docs/.vitepress/dist/ +.claude-bridge/ +org-templates/**/.env +org-templates/**/.auth-token + +# Migration additions (2026-04-13) +.initial_prompt_done +.claude-bridge/ +.claude/scheduled_tasks.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..2e38aa1f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "awareness-memory": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@awareness-sdk/local", + "mcp" + ] + }, + "molecule": { + "type": "stdio", + "command": "node", + "args": ["./mcp-server/dist/index.js"], + "env": { + "MOLECULE_URL": "http://localhost:8080" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..05659822 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,177 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +Molecule AI is a platform for orchestrating AI agent workspaces that form an organizational hierarchy. Workspaces register with a central platform, communicate via A2A protocol, and are visualized on a drag-and-drop canvas. + +## Architecture + +``` +Canvas (Next.js :3000) ←WebSocket→ Platform (Go :8080) ←HTTP→ Postgres + Redis + ↑ + Workspace A ←──A2A──→ Workspace B + (pluggable runtimes) + ↑ register/heartbeat ↑ + └───── Platform ─────┘ +``` + +Three main components: +- **Platform** (`platform/`): Go/Gin control plane — workspace CRUD, registry, discovery, WebSocket hub, liveness monitoring +- **Canvas** (`canvas/`): Next.js 15 + React Flow (@xyflow/react v12) + Zustand + Tailwind — visual workspace graph +- **Workspace Runtime** (`workspace-template/`): A2A runtime layer with pluggable adapters — LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, OpenClaw — registers with platform and sends heartbeats + +## Build & Run Commands + +### Infrastructure +```bash +./infra/scripts/setup.sh # Start Postgres, Redis, Langfuse; run migrations +./infra/scripts/nuke.sh # Tear down everything, remove volumes +``` + +### Platform (Go) +```bash +cd platform +go build ./cmd/server # Build +go run ./cmd/server # Run (requires Postgres + Redis running) +``` +Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT` (defaults: postgres://dev:dev@localhost:5432/molecule?sslmode=prefer, redis://localhost:6379, 8080). + +### Canvas (Next.js) +```bash +cd canvas +npm install +npm run dev # Dev server on :3000 +npm run build && npm start # Production +``` +Env vars: `NEXT_PUBLIC_PLATFORM_URL` (default http://localhost:8080), `NEXT_PUBLIC_WS_URL` (default ws://localhost:8080/ws). + +### Integration Tests +```bash +bash test_api.sh # Runs 34 API tests against localhost:8080 +``` +Requires platform running. Tests full CRUD, registry, heartbeat, discovery, peers, access control, events, degraded/recovery lifecycle. + +### Docker Compose +```bash +docker compose -f docker-compose.infra.yml up -d # Infra only +docker compose up # Full stack +``` + +## Key Architectural Patterns + +### Import Cycle Prevention +The platform uses function injection to avoid Go import cycles between ws, registry, and events packages: +- `ws.NewHub(canCommunicate AccessChecker)` — Hub accepts `registry.CanCommunicate` as a function +- `registry.StartLivenessMonitor(ctx, onOffline OfflineHandler)` — Liveness accepts broadcaster callback +- Wiring happens in `platform/cmd/server/main.go` + +### Communication Rules (`registry/access.go`) +`CanCommunicate(callerID, targetID)` determines if two workspaces can talk: +- Same workspace → allowed +- Siblings (same parent_id) → allowed +- Root-level siblings (both parent_id IS NULL) → allowed +- Parent ↔ child → allowed +- Everything else → denied + +### JSONB Gotcha +When inserting Go `[]byte` (from `json.Marshal`) into Postgres JSONB columns, you must: +1. Convert to `string()` first +2. Use `::jsonb` cast in SQL + +lib/pq treats `[]byte` as `bytea`, not JSONB. + +### WebSocket Events Flow +1. Action occurs (register, heartbeat, etc.) +2. `broadcaster.RecordAndBroadcast()` inserts into `structure_events` table + publishes to Redis pub/sub +3. Redis subscriber relays to WebSocket hub +4. Hub broadcasts to canvas clients (all events) and workspace clients (filtered by CanCommunicate) + +### Canvas State Management +- Initial load: HTTP fetch from `GET /workspaces` → Zustand hydrate +- Real-time updates: WebSocket events → `applyEvent()` in Zustand store +- Position persistence: `onNodeDragStop` → `PATCH /workspaces/:id` with `{x, y}` + +### Workspace Lifecycle +`provisioning` → `online` (on register) → `degraded` (error_rate > 0.5) → `online` (recovered) → `offline` (Redis TTL expired) → `removed` (deleted) + +## Platform API Routes + +| Method | Path | Handler | +|--------|------|---------| +| GET | /health | inline | +| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go | +| POST | /registry/register | registry.go | +| POST | /registry/heartbeat | registry.go | +| POST | /registry/update-card | registry.go | +| GET | /registry/discover/:id | discovery.go | +| GET | /registry/:id/peers | discovery.go | +| POST | /registry/check-access | discovery.go | +| GET | /events[/:workspaceId] | events.go | +| GET | /ws | socket.go | + +## Database + +5 migration files in `platform/migrations/`. Key tables: `workspaces` (core entity with status, agent_card JSONB, heartbeat columns), `canvas_layouts` (x/y position), `structure_events` (append-only event log), `agents`, `workspace_secrets`. + +The platform auto-discovers and runs migrations on startup from several candidate paths. + + +# Awareness Memory Integration + +MANDATORY agent policy - follow for every task. + +## Awareness Memory Integration (MANDATORY) + +awareness_* = cross-session persistent memory (past decisions, knowledge, tasks). +Other tools = current codebase navigation (file search, code index). +Use BOTH - they serve different purposes. + +STEP 1 - SESSION START: + Call awareness_init(source="codex") -> get session_id, review context. + If active_skills[] is returned: skill = reusable procedure done 2+ times; + summary = injectable instruction, methods = steps. Apply matching skills to tasks. + +STEP 2 - RECALL BEFORE WORK (progressive disclosure): + 1. awareness_recall(semantic_query=..., keyword_query=..., detail='summary') → lightweight index. + 2. Review summaries/scores, pick relevant IDs. + 3. awareness_recall(detail='full', ids=[...]) → expand only what you need. + +STEP 3 - RECORD EVERY CHANGE: + After EVERY code edit, decision, or bug fix: + awareness_record(content=, + insights={knowledge_cards:[...], action_items:[...], risks:[...]}) + Content should be RICH and DETAILED — include reasoning, key code snippets, + user quotes, alternatives considered, and files changed. Do NOT compress into + a single-line summary. The content IS the memory — more detail = better recall. + Include insights to create searchable knowledge in ONE step (recommended). + Skipping = permanent data loss. + +STEP 4 - CATEGORY GUIDE (for insights.knowledge_cards): + - decision = choice made between alternatives. + - problem_solution = bug/problem plus the fix that resolved it. + - workflow = process, setup, or configuration steps only. + - pitfall = blocker, warning, or limitation without a fix yet. + - insight = reusable pattern or general learning. + - skill = reusable procedure done 2+ times; summary = injectable instruction, methods = steps. + - key_point = important technical fact when nothing else fits. + Never default everything to workflow. + +STEP 5 - SESSION END: + awareness_record(content=[step1, step2, ...], insights={...}) with final summary. + +BACKFILL (if applicable): + If MCP connected late: awareness_record(content=) + +RULES VERSION: Pass rules_version="2" to awareness_init so the server knows you have these rules. +If the server returns _setup_action, the rules have been updated — follow the instruction to re-sync. + +NOTE: memory_id from X-Awareness-Memory-Id header. source/actor/event_type auto-inferred. + +## Codex-Specific Notes + +- Call awareness_init at task start before reading any files. + +- After each code patch, call awareness_record with the change description. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2532e48d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,351 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Molecule AI is a platform for orchestrating AI agent workspaces that form an organizational hierarchy. Workspaces register with a central platform, communicate via A2A protocol, and are visualized on a drag-and-drop canvas. + +## Ecosystem Context + +Before research, strategy, or design work, skim **`docs/ecosystem-watch.md`** — +it catalogs adjacent agent projects (Holaboss, Hermes, gstack, …) with +overlap / differentiation / terminology-collision notes. Cross-referenced +from `PLAN.md` and `README.md`; it's the canonical starting point for +"what else is out there." + +## Architecture + +``` +Canvas (Next.js :3000) ←WebSocket→ Platform (Go :8080) ←HTTP→ Postgres + Redis + ↑ + Workspace A ←──A2A──→ Workspace B + (Python agents) + ↑ register/heartbeat ↑ + └───── Platform ─────┘ +``` + +Four main components: +- **Platform** (`platform/`): Go/Gin control plane — workspace CRUD, registry, discovery, WebSocket hub, liveness monitoring +- **Canvas** (`canvas/`): Next.js 15 + React Flow (@xyflow/react v12) + Zustand + Tailwind — visual workspace graph +- **Workspace Runtime** (`workspace-template/`): Unified Docker image with pluggable adapter system — supports LangGraph, Claude Code, OpenClaw, DeepAgents, CrewAI, AutoGen. Adapters in `workspace-template/adapters/`. Deps installed at startup via `entrypoint.sh`. +- **molecli** (`platform/cmd/cli/`): Go TUI dashboard (Bubbletea + Lipgloss) — real-time workspace monitoring, event log, health overview, delete/filter operations + +## Build & Run Commands + +### Infrastructure +```bash +./infra/scripts/setup.sh # Start Postgres, Redis, Langfuse; run migrations +./infra/scripts/nuke.sh # Tear down everything, remove volumes +``` + +### Platform (Go) +```bash +cd platform +go build ./cmd/server # Build server +go run ./cmd/server # Run server (requires Postgres + Redis running) +go build -o molecli ./cmd/cli # Build TUI dashboard +./molecli # Run TUI dashboard (requires platform running) +``` +Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:` URLs to container hostnames; auto-detected via `/.dockerenv`). + +**Plugin install safeguards** (bound the cost of a single `POST /workspaces/:id/plugins` install so a slow/malicious source can't tie up a handler): +- `PLUGIN_INSTALL_BODY_MAX_BYTES` — max request body size (default `65536` = 64 KiB) +- `PLUGIN_INSTALL_FETCH_TIMEOUT` — duration string; whole fetch+copy deadline (default `5m`) +- `PLUGIN_INSTALL_MAX_DIR_BYTES` — max staged-tree size (default `104857600` = 100 MiB) + +See `docs/plugins/sources.md` for the two-axis source/shape plugin model. + +`molecli` reads `MOLECLI_URL` (default http://localhost:8080) to locate the platform. Logs are written to `molecli.log` in the working directory (already covered by `*.log` in `.gitignore`). + +### Canvas (Next.js) +```bash +cd canvas +npm install +npm run dev # Dev server on :3000 +npm run build && npm start # Production +``` +Env vars: `NEXT_PUBLIC_PLATFORM_URL` (default http://localhost:8080), `NEXT_PUBLIC_WS_URL` (default ws://localhost:8080/ws). + +### Workspace Images +```bash +bash workspace-template/build-all.sh # Build base + ALL runtime images +bash workspace-template/build-all.sh claude-code # Build base + specific runtime only +``` +Each runtime has its own Docker image extending `workspace-template:base`, with deps pre-installed for fast startup. The base Dockerfile (`workspace-template/Dockerfile`) builds `:base`, then each `adapters/*/Dockerfile` extends it (e.g. `claude_code/Dockerfile` installs the `claude` CLI). **Always use `build-all.sh`** — it builds base first, then all runtimes in order. No `:latest` tag — each runtime uses its own tag to avoid confusion. + +| Runtime | Image Tag | Key Deps | +|---------|-----------|----------| +| langgraph | `workspace-template:langgraph` | langchain-anthropic, langgraph | +| claude-code | `workspace-template:claude-code` | claude-agent-sdk (pip), @anthropic-ai/claude-code (npm) | +| openclaw | `workspace-template:openclaw` | openclaw deps | +| crewai | `workspace-template:crewai` | crewai | +| autogen | `workspace-template:autogen` | autogen | +| deepagents | `workspace-template:deepagents` | deepagents | + +Templates are framework presets in `workspace-configs-templates/`: `claude-code-default`, `langgraph`, `openclaw`, `deepagents`. Agent roles are configured after deployment via Config tab or API. + +For Claude Code runtime, write your OAuth token to `workspace-configs-templates/claude-code-default/.auth-token`. + +### Pre-commit Hook +```bash +git config core.hooksPath .githooks # Install hooks (agents do this via initial_prompt) +``` +Enforces: `'use client'` on hook-using `.tsx` files, dark theme (no white/light), no SQL injection (`fmt.Sprintf` with SQL), no leaked secrets (`sk-ant-`, `ghp_`, `AKIA`). Commit is rejected until violations are fixed — agents cannot bypass this. + +### Plugins +Shared plugins in `plugins/` are auto-loaded by every workspace: +- **`molecule-dev`**: Codebase conventions (rules injected into CLAUDE.md) + `review-loop` skill for multi-round QA cycles +- **`superpowers`**: `verification-before-completion`, `test-driven-development`, `systematic-debugging`, `writing-plans` +- **`ecc`**: General Claude Code guardrails + +### Scripts +```bash +bash scripts/setup-default-org.sh # Create PM + 3 teams (Marketing/Research/Dev) via API +OPENAI_API_KEY=... bash scripts/test-a2a-cross-runtime.sh # E2E: Claude Code ↔ OpenClaw A2A test +OPENAI_API_KEY=... bash scripts/test-team-e2e.sh # E2E: Multi-template team + A2A +``` + +### Unit Tests +```bash +cd platform && go test -race ./... # 487 Go tests (handlers, registry, provisioner, CLI, delegation, org, channels, wsauth — sqlmock + miniredis) +cd canvas && npm test # 352 Vitest tests (store, components, hydration, buildTree, secrets API, org template import) +cd workspace-template && python -m pytest -v # 1078 pytest tests (adds platform_auth token store for Phase 30.1) +cd sdk/python && python -m pytest -v # 87 SDK tests (agentskills.io spec validator, CLI, AgentskillsAdaptor round-trip, workspace/org/channel validators) +``` + +### Integration Tests +```bash +bash tests/e2e/test_api.sh # 62 API tests against localhost:8080 +bash tests/e2e/test_a2a_e2e.sh # 22 A2A end-to-end tests (requires 2 online agents) +bash tests/e2e/test_activity_e2e.sh # 25 activity/task E2E tests (requires 1 online agent) +bash tests/e2e/test_comprehensive_e2e.sh # 68 checks — ALL endpoints, memory, runtime, bundles, approvals +``` +`test_api.sh` requires platform running. Tests full CRUD, registry, heartbeat, discovery, peers, access control, events, degraded/recovery lifecycle, activity logging, current task tracking, bundle round-trip (export → delete → import → verify). + +`test_a2a_e2e.sh` requires platform + two provisioned agents (Echo Agent, SEO Agent) running with a valid `OPENROUTER_API_KEY`. Tests message/send, JSON-RPC wrapping, error handling, peer discovery, agent cards, heartbeat. Timeout configurable via `A2A_TIMEOUT` env var (default 120s). + +`test_activity_e2e.sh` requires platform + one online agent. Tests A2A communication logging (request/response capture, duration, method), agent self-reported activity, type filtering, current task visibility via heartbeat, cross-workspace activity isolation, edge cases. + +### MCP Server +```bash +cd mcp-server +npm install && npm run build # Build MCP server +node dist/index.js # Run (stdio transport) +``` +Exposes 61 tools for managing Molecule AI from Claude Code, Cursor, Codex, or any MCP client. Includes workspace CRUD, async delegation, plugins (install/uninstall/list), global secrets, pause/resume, org import, A2A chat, approvals, memory, files, config, discovery, bundles, templates, traces, activity logs, and social channels (add/update/remove/send/test). Configured in `.mcp.json`. Env: `MOLECULE_URL` (default http://localhost:8080). + +### CI Pipeline +GitHub Actions (`.github/workflows/ci.yml`) runs on push to main and PRs: +- **platform-build**: Go build, vet, `go test -race` with coverage profiling (25% baseline threshold) +- **canvas-build**: npm build, `vitest run` (no `--passWithNoTests` -- tests must exist and pass) +- **mcp-server-build**: npm build +- **python-lint**: `pytest --cov=. --cov-report=term-missing` (pytest-cov enabled) + +### Docker Compose +```bash +docker compose -f docker-compose.infra.yml up -d # Infra only +docker compose up # Full stack +``` + +## Key Architectural Patterns + +### Import Cycle Prevention +The platform uses function injection to avoid Go import cycles between ws, registry, and events packages: +- `ws.NewHub(canCommunicate AccessChecker)` — Hub accepts `registry.CanCommunicate` as a function +- `registry.StartLivenessMonitor(ctx, onOffline OfflineHandler)` — Liveness accepts broadcaster callback +- `registry.StartHealthSweep(ctx, checker ContainerChecker, interval, onOffline)` — Health sweep accepts Docker checker interface +- Wiring happens in `platform/cmd/server/main.go` — init order: `wh → onWorkspaceOffline → liveness/healthSweep → router` + +### Container Health Detection +Three layers detect dead containers (e.g. Docker Desktop crash): +1. **Passive (Redis TTL):** 60s heartbeat key expires → liveness monitor → auto-restart +2. **Proactive (Health Sweep):** `registry.StartHealthSweep` polls Docker API every 15s → catches dead containers faster +3. **Reactive (A2A Proxy):** On connection error, checks `provisioner.IsRunning()` → immediate offline + restart + +All three call `onWorkspaceOffline` which broadcasts `WORKSPACE_OFFLINE` + `go wh.RestartByID()`. Redis cleanup uses shared `db.ClearWorkspaceKeys()`. + +### Template Resolution (Create) +Runtime detection happens **before** DB insert: if `payload.Runtime` is empty and a template is specified, the handler reads `runtime:` from `configsDir/template/config.yaml` first. If still empty, defaults to `"langgraph"`. This ensures the correct runtime (e.g. `claude-code`) is persisted in the DB and used for container image selection. + +When a workspace specifies a template that doesn't exist, the Create handler falls back: +1. Check `os.Stat(configsDir/template)` — use if exists +2. Try `{runtime}-default` template (e.g. `claude-code-default/`) +3. Generate default config via `ensureDefaultConfig()` (includes `.auth-token` copy for CLI runtimes) + +### Communication Rules (`registry/access.go`) +`CanCommunicate(callerID, targetID)` determines if two workspaces can talk: +- Same workspace → allowed +- Siblings (same parent_id) → allowed +- Root-level siblings (both parent_id IS NULL) → allowed +- Parent ↔ child → allowed +- Everything else → denied + +The A2A proxy (`POST /workspaces/:id/a2a`) enforces this for agent-to-agent calls. Canvas requests (no `X-Workspace-ID`), self-calls, and system callers (`webhook:*`, `system:*`, `test:*` prefixes via `isSystemCaller()` in `a2a_proxy.go`) bypass the check. + +### JSONB Gotcha +When inserting Go `[]byte` (from `json.Marshal`) into Postgres JSONB columns, you must: +1. Convert to `string()` first +2. Use `::jsonb` cast in SQL + +lib/pq treats `[]byte` as `bytea`, not JSONB. + +### WebSocket Events Flow +1. Action occurs (register, heartbeat, etc.) +2. `broadcaster.RecordAndBroadcast()` inserts into `structure_events` table + publishes to Redis pub/sub +3. Redis subscriber relays to WebSocket hub +4. Hub broadcasts to canvas clients (all events) and workspace clients (filtered by CanCommunicate) + +### Canvas State Management +- Initial load: HTTP fetch from `GET /workspaces` → Zustand hydrate +- Real-time updates: WebSocket events → `applyEvent()` in Zustand store +- Position persistence: `onNodeDragStop` → `PATCH /workspaces/:id` with `{x, y}` +- Embedded sub-workspaces: `nestNode` sets `hidden: !!targetId` on child nodes; children render as recursive `TeamMemberChip` components inside parent (up to 3 levels), not as separate canvas nodes. Use `n.data.parentId` (not React Flow's `n.parentId`) for hierarchy lookups. +- Chat: two sub-tabs — "My Chat" (user↔agent, `source=canvas`) and "Agent Comms" (agent↔agent A2A traffic, `source=agent`). History loaded from `GET /activity` with source filter. Real-time via `A2A_RESPONSE` + `AGENT_MESSAGE` WebSocket events. Conversation history (last 20 messages) sent via `params.metadata.history` in A2A `message/send` requests. +- Config save: "Save & Restart" writes config.yaml and auto-restarts the workspace. "Save" writes only (shows restart banner). Secrets POST/DELETE auto-restart on the platform side. + +### Initial Prompt +Agents can auto-execute a prompt on startup before any user interaction. Configure via `initial_prompt` (inline string) or `initial_prompt_file` (path relative to config dir) in `config.yaml`. After the A2A server is ready, `main.py` sends the prompt as a `message/send` to self. A `.initial_prompt_done` marker file prevents re-execution on restart. Org templates support `initial_prompt` on both `defaults` (all agents) and per-workspace (overrides default). + +**Important:** Initial prompts must NOT send A2A messages (delegate_task, send_message_to_user) — other agents may not be ready. Keep them local: clone repo, read docs, save to memory, wait for tasks. + +### Workspace Lifecycle +`provisioning` → `online` (on register) → `degraded` (error_rate > 0.5) → `online` (recovered) → `offline` (Redis TTL expired OR health sweep detects dead container) → auto-restart → `provisioning` → ... → `removed` (deleted). Any state → `paused` (user pauses) → `provisioning` (user resumes). Paused workspaces skip health sweep, liveness monitor, and auto-restart. + +## Platform API Routes + +| Method | Path | Handler | +|--------|------|---------| +| GET | /health | inline | +| GET | /metrics | metrics.Handler() — Prometheus text format (v0.0.4); no auth, scrape-safe | +| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go | +| GET/PATCH | /workspaces/:id/config | workspace.go | +| GET/POST | /workspaces/:id/memory | workspace.go | +| DELETE | /workspaces/:id/memory/:key | workspace.go | +| POST/PATCH/DELETE | /workspaces/:id/agent | agent.go | +| POST | /workspaces/:id/agent/move | agent.go | +| GET/POST/PUT | /workspaces/:id/secrets | secrets.go (POST/PUT auto-restarts workspace) | +| DELETE | /workspaces/:id/secrets/:key | secrets.go (DELETE auto-restarts workspace) | +| GET | /workspaces/:id/model | secrets.go | +| GET | /settings/secrets | secrets.go — list global secrets (keys only, values masked) | +| PUT/POST | /settings/secrets | secrets.go — set a global secret {key, value} | +| DELETE | /settings/secrets/:key | secrets.go — delete a global secret | +| GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets | +| WS | /workspaces/:id/terminal | terminal.go | +| POST | /workspaces/:id/expand | team.go | +| POST | /workspaces/:id/collapse | team.go | +| POST/GET | /workspaces/:id/approvals | approvals.go | +| POST | /workspaces/:id/approvals/:id/decide | approvals.go | +| GET | /approvals/pending | approvals.go | +| POST/GET | /workspaces/:id/memories | memories.go | +| DELETE | /workspaces/:id/memories/:id | memories.go | +| GET | /workspaces/:id/traces | traces.go | +| GET/POST | /workspaces/:id/activity | activity.go | +| POST | /workspaces/:id/notify | activity.go (agent→user push message via WS) | +| POST | /workspaces/:id/restart | workspace.go | +| POST | /workspaces/:id/pause | workspace.go (stops container, status→paused) | +| POST | /workspaces/:id/resume | workspace.go (re-provisions paused workspace) | +| POST | /workspaces/:id/a2a | workspace.go | +| POST | /workspaces/:id/delegate | delegation.go (async fire-and-forget) | +| GET | /workspaces/:id/delegations | delegation.go (list delegation status) | +| GET/POST | /workspaces/:id/schedules | schedules.go (cron CRUD) | +| PATCH/DELETE | /workspaces/:id/schedules/:scheduleId | schedules.go | +| POST | /workspaces/:id/schedules/:scheduleId/run | schedules.go (manual trigger) | +| GET | /workspaces/:id/schedules/:scheduleId/history | schedules.go (past runs) | +| GET/POST | /workspaces/:id/channels | channels.go (social channel CRUD) | +| PATCH/DELETE | /workspaces/:id/channels/:channelId | channels.go | +| POST | /workspaces/:id/channels/:channelId/send | channels.go (outbound message) | +| POST | /workspaces/:id/channels/:channelId/test | channels.go (test connection) | +| GET | /channels/adapters | channels.go (list available platforms) | +| POST | /channels/discover | channels.go (auto-detect chats for a bot token) | +| POST | /webhooks/:type | channels.go (incoming social webhook) | +| GET | /workspaces/:id/shared-context | templates.go | +| GET/PUT/DELETE | /workspaces/:id/files[/*path] | templates.go | +| GET/PUT | /canvas/viewport | viewport.go | +| GET | /templates | templates.go | +| POST | /templates/import | templates.go | +| POST | /registry/register | registry.go | +| POST | /registry/heartbeat | registry.go | +| POST | /registry/update-card | registry.go | +| GET | /registry/discover/:id | discovery.go | +| GET | /registry/:id/peers | discovery.go | +| POST | /registry/check-access | discovery.go | +| GET | /plugins | plugins.go (list registry; supports `?runtime=` filter) | +| GET | /plugins/sources | plugins.go (list registered install-source schemes) | +| GET/POST/DELETE | /workspaces/:id/plugins[/:name] | plugins.go — list, install (`{"source":"scheme://spec"}`), uninstall per-workspace | +| GET | /workspaces/:id/plugins/available | plugins.go (filtered by workspace runtime) | +| GET | /workspaces/:id/plugins/compatibility?runtime=X | plugins.go (preflight runtime-change check) | +| GET | /bundles/export/:id | bundle.go | +| POST | /bundles/import | bundle.go | +| GET | /org/templates | org.go (list available org templates) | +| POST | /org/import | org.go (import entire org hierarchy from YAML) || GET | /events[/:workspaceId] | events.go | +| GET | /ws | socket.go | + +## Database + +16 migration files in `platform/migrations/`. Key tables: `workspaces` (core entity with status, runtime, agent_card JSONB, heartbeat columns, current_task, awareness_namespace, workspace_dir), `canvas_layouts` (x/y position), `structure_events` (append-only event log), `activity_logs` (A2A communications, task updates, agent logs, errors), `workspace_schedules` (cron tasks with expression, timezone, prompt, run history), `workspace_channels` (social channel integrations — Telegram, Slack, etc., with JSONB config and allowlist), `agents`, `workspace_secrets`, `global_secrets`, `agent_memories` (HMA scoped memory), `approvals`. + +The platform auto-discovers and runs migrations on startup from several candidate paths. + + +# Project Memory (Awareness MCP) + +> IMPORTANT: These instructions override default behavior. You must follow them exactly. + +## Awareness Memory Integration (MANDATORY) + +awareness_* = cross-session persistent memory (past decisions, knowledge, tasks). +Other tools = current codebase navigation (file search, code index). +Use BOTH - they serve different purposes. + +STEP 1 - SESSION START: + Call awareness_init(source="claude-code") -> get session_id, review context. + If active_skills[] is returned: skill = reusable procedure done 2+ times; + summary = injectable instruction, methods = steps. Apply matching skills to tasks. + +STEP 2 - RECALL BEFORE WORK (progressive disclosure): + 1. awareness_recall(semantic_query=..., keyword_query=..., detail='summary') → lightweight index. + 2. Review summaries/scores, pick relevant IDs. + 3. awareness_recall(detail='full', ids=[...]) → expand only what you need. + +STEP 3 - RECORD EVERY CHANGE: + After EVERY code edit, decision, or bug fix: + awareness_record(content=, + insights={knowledge_cards:[...], action_items:[...], risks:[...]}) + Content should be RICH and DETAILED — include reasoning, key code snippets, + user quotes, alternatives considered, and files changed. Do NOT compress into + a single-line summary. The content IS the memory — more detail = better recall. + Include insights to create searchable knowledge in ONE step (recommended). + Skipping = permanent data loss. + +STEP 4 - CATEGORY GUIDE (for insights.knowledge_cards): + - decision = choice made between alternatives. + - problem_solution = bug/problem plus the fix that resolved it. + - workflow = process, setup, or configuration steps only. + - pitfall = blocker, warning, or limitation without a fix yet. + - insight = reusable pattern or general learning. + - skill = reusable procedure done 2+ times; summary = injectable instruction, methods = steps. + - key_point = important technical fact when nothing else fits. + Never default everything to workflow. + +STEP 5 - SESSION END: + awareness_record(content=[step1, step2, ...], insights={...}) with final summary. + +BACKFILL (if applicable): + If MCP connected late: awareness_record(content=) + +RULES VERSION: Pass rules_version="2" to awareness_init so the server knows you have these rules. +If the server returns _setup_action, the rules have been updated — follow the instruction to re-sync. + +NOTE: memory_id from X-Awareness-Memory-Id header. source/actor/event_type auto-inferred. + +## Compliance Check + +Before responding to ANY user request: + +1. Have you called awareness_init yet this session? If not, call it NOW. + +2. Did you just edit a file? Call awareness_record(content=, insights={...}) IMMEDIATELY. + +3. Is the user asking about past work? Call awareness_recall FIRST. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..495945cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,45 @@ +Business Source License 1.1 + +Licensor: Molecule AI + +Licensed Work: Molecule AI + +The Licensed Work is copyright © 2025 Agent Molecule + +Additional Use Grant: + +You may make use of the Licensed Work, provided that you may not use the +Licensed Work to offer a competing product or service that is substantially +similar to the Licensed Work. A "competing product or service" means a +product or service that is primarily intended to provide an organizational +control plane for heterogeneous AI agent teams, including but not limited +to agent governance, memory management, or team orchestration features +similar to those offered by Agent Molecule. + +Personal, internal, and non-commercial use is permitted without restriction. + +Change Date: January 1, 2029 + +Change License: Apache License, Version 2.0 + +Terms: + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make production use of the Licensed Work, provided such use +does not violate the Additional Use Grant. + +If you are not in compliance with the terms of this license, you must +immediately cease all use of the Licensed Work and notify the Licensor. + +The Licensed Work is provided "AS IS" without warranties of any kind, +express or implied, including but not limited to warranties of +merchantability, fitness for a particular purpose, and non-infringement. +In no event shall the Licensor be liable for any claim, damages, or other +liability arising from the use of the Licensed Work. + +On the Change Date, the terms of the Change License apply to the Licensed +Work, and this license terminates automatically. + +For the full text of the Change License (Apache License, Version 2.0), +see: https://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..ff49f4ba --- /dev/null +++ b/PLAN.md @@ -0,0 +1,304 @@ +# PLAN.md — Molecule AI Build Plan + +> Completed phases (1–11, 13–14) are documented in `/docs` and removed from here. +> This file tracks only **in-progress and upcoming work**. + +--- + +## Completed Phases (see /docs for details) + +| Phase | Name | Docs | +|-------|------|------| +| 1 | Core Loop | `docs/architecture/architecture.md`, `CLAUDE.md` | +| 2 | E2E Validation | `CLAUDE.md` (build/test commands) | +| 3 | Hierarchy & Communication | `docs/api-protocol/communication-rules.md` | +| 4 | Provisioner | `docs/architecture/provisioner.md` | +| 5 | Agent Management | `CLAUDE.md` (API routes) | +| 6 | Bundle Export/Import | `docs/agent-runtime/bundle-system.md` | +| 7 | Team Expansion | `docs/agent-runtime/team-expansion.md` | +| 8 | Human-in-the-Loop Approvals | `docs/agent-runtime/system-prompt-structure.md` | +| 9 | Hierarchical Memory | `docs/architecture/memory.md` | +| 10 | Observability (Langfuse) | `docs/development/observability.md` | +| 11 | Canvas Polish & UX | `docs/frontend/canvas.md` | +| 13 | Runtime Enhancements | `docs/agent-runtime/workspace-runtime.md` | +| 14 | Production Hardening | `docs/architecture/provisioner.md`, `CLAUDE.md` | +| 15 | Per-Workspace Dir | PR #38 — `workspace_dir` per workspace | +| 16 | Plugin System | PR #39 — per-workspace plugins with registry | +| 17 | Agent GitHub Access | PR #40 — git/gh in images, GITHUB_TOKEN env | +| 18 | File Browser Lazy Loading | PR #37 — depth=1, path traversal protection | +| 19 | MCP Full Coverage | PR #40 — 52→54 tools (plugins, global secrets, pause/resume, org, delegation) | +| 20 | Canvas UX Sprint | PRs #4, #21, #39 — Settings Panel, Onboarding, Plugins UI, Pause/Resume | +| 21 | Claude Agent SDK Migration | PR #48 — `ClaudeSDKExecutor` replaces CLI subprocess | +| 22 | Cron Scheduling | PR #49 — recurring tasks via cron expressions, Canvas Schedule tab | +| 23 | Code Quality & Multi-Provider | PR #50 — model fallback, DeepAgents full SDK, 7 LLM providers, 100% test coverage | +| 24 | Async Delegation | PR #41 — non-blocking delegation with status polling, `check_delegation_status` tool | +| 25 | Social Channels | PR #54 — adapter-based Telegram integration, Canvas Channels tab, 7 MCP tools, hot reload, multi-chat IDs, auto-detect, /start auto-reply, full Telegram Bot API audit fixes | +| 26 | Auth Env Vars | PR #55 — `required_env` config replaces `.auth-token` files, env-var only path; reno-stars 15-agent org template | +| 27 | Channel Polish & Org Auto-link | PR #56 — poller lifetime fix (bgCtx), Restart Pending button (only when needed), org template `channels:` field auto-links Telegram on import | + +--- + +## Phase 12: Code Sandbox — PARTIAL + +> MVP done (subprocess + Docker backends). Production backends not started. + +- [x] `run_code` tool — `tools/sandbox.py` +- [x] Docker-in-Docker backend (MVP) — throwaway container with resource limits +- [ ] Firecracker backend (production) — MicroVM isolation, faster cold starts +- [ ] E2B backend (cloud) — cloud-hosted via E2B API +- [x] Sandbox config — `SandboxConfig` dataclass in config.py + +--- + +## Phase 20: Canvas UX Sprint — MOSTLY COMPLETE + +> UX specs created by UIUX Designer agent. See `docs/ux-specs/` for full specs. + +### 20.1 Settings Panel (Global Secrets UI) — DONE +**Spec**: `docs/ux-specs/ux-spec-settings-panel.md` + +- [x] Gear icon in canvas top bar (Cmd+, shortcut) +- [x] Slide-over drawer (480px, right-anchored) +- [x] Service groups (GitHub, Anthropic, OpenRouter, Custom) +- [x] CRUD: add, view (masked), edit, delete secrets +- [x] Empty state with guided setup +- [x] Unsaved changes guard on close + +### 20.2 Onboarding / Deploy Interception — DONE +**Spec**: `docs/ux-specs/ux-spec-onboarding-interception.md` + +- [x] Pre-deploy secret check — detect missing API keys per runtime +- [x] Missing Keys Modal — inline form, only asks for what's needed +- [x] Provisioning timeout → named error state with recovery actions +- [x] No dead ends — every error has a fix action + +### 20.3 Canvas UI Improvements — PARTIAL +**Spec**: `docs/ux-specs/ux-spec-canvas-improvements.md` + +- [x] Plugins install/uninstall in Skills tab (PR #39) +- [x] Pause/resume from context menu +- [x] Org template import from canvas (PR — `OrgTemplatesSection` in TemplatePalette) +- [ ] Workspace search (Cmd+K) +- [ ] Batch operations + +--- + +## Phase 30: SaaS — Remote Workspaces & Cross-Network Federation — IN PROGRESS + +**Goal:** let a Python agent running on a laptop in another city boot, +register, authenticate, accept A2A from its parent PM on the platform, +and appear on the canvas as a first-class workspace. + +**Why now:** the self-hostable single-box model has landed; the next +meaningful expansion is letting orgs span machines and networks. This +is the step that turns Molecule AI from "Docker-compose on one box" into +a multi-tenant SaaS-shaped product. + +**Design thesis:** ride the existing `runtime='external'` escape hatch. +Every Docker-touching handler already short-circuits when a workspace +is external. We don't need a parallel subsystem — we need to close +four small gaps and add per-workspace auth. See +[`docs/remote-workspaces-readiness.md`](docs/remote-workspaces-readiness.md) +for the full code audit. + +### Shipping order (eight bounded steps, ~2 weeks to GA) + +- [x] **30.1 Workspace auth tokens** — foundation; prevents spoofing. + New `workspace_auth_tokens` table; `POST /registry/register` issues + a token; middleware validates `Authorization: Bearer ` on + `/registry/heartbeat`, `/registry/update-card`. Lazy bootstrap so + in-flight workspaces upgrade gracefully. Transparent to local + containers — provisioner carries the token through the existing env-var + pattern. No feature flag. + +- [x] **30.2 Secrets pull endpoint** — `GET /workspaces/:id/secrets/values` + returns decrypted secrets JSON, gated by the 30.1 token. Local agents + can use it too (removes env-at-create coupling for rotating secrets). + +- [x] **30.3 Plugin tarball download** — `GET /plugins/:name/download` + returns a tarball; agent unpacks locally. Replaces Docker-exec plugin + install for remote agents. Behind `REMOTE_PLUGIN_DOWNLOAD_ENABLED`. + +- [x] **30.4 Workspace state polling** — `GET /workspaces/:id/state` + returns `{status, paused, deleted_at, pending_events[]}` as a drop-in + for the WebSocket feed remote agents can't reach. Behind + `REMOTE_STATE_POLLING_ENABLED`. + +- [x] **30.5 A2A proxy token validation** — the proxy enforces the caller's + auth token on `POST /workspaces/:id/a2a`. Mutual auth between agents. + +- [x] **30.6 Direct sibling discovery + URL caching** — agents call + `GET /registry/{parent_id}/peers` once, cache sibling URLs, call them + directly for A2A. Resilient to brief platform outages. + +- [x] **30.7 Poll-liveness for external runtime** — `LivenessChecker` + interface in `registry/`; `PollLiveness` marks offline if no heartbeat + in 90s. Docker checker becomes one implementation, poll-liveness + another. Health sweep routes by runtime. Behind + `REMOTE_LIVENESS_POLLING_ENABLED`. + +- [x] **30.8 Remote-agent SDK + docs** — `sdk/python/molecule_agent/` + thin client: register → pull secrets → run A2A loop → poll state → + heartbeat. Working `examples/remote-agent/` a new user can run on a + laptop. Remove the three feature flags. Remote workspaces become GA. + +### Out of scope for Phase 30 + +- Mutual TLS / platform-identity verification from the agent side. + Agent trusts any platform URL in its env. Defer until real multi- + tenant deployment forces the question. +- Agent-to-agent mesh across NATs. Direct sibling calls only work when + siblings are reachable from each other. Behind-NAT ↔ behind-NAT needs + a relay — defer to Phase 31. +- Platform-managed persistent state for remote agents. Remote agents + own their filesystem; platform never mounts. + +### Success criteria + +- `examples/remote-agent/` boots on a laptop disconnected from the + platform's LAN, registers, receives a task from parent PM via A2A, + returns a result, appears on the canvas. +- `tests/e2e/test_federation.sh` spawns a second platform instance + + remote agent pointing at the first; both platforms see the agent as + a workspace in the right state. +- Spoofing test: attempt to impersonate a workspace with a guessed ID + but no token → 401. + +--- + +## PR Workflow Rules + +All PRs must follow this checklist: + +1. **Branch**: Never push to main. Always create a feature/fix branch. +2. **Code Review**: Run `/code-review` skill and fix all issues before requesting merge. +3. **Tests**: All existing tests must pass. New features require new tests. +4. **Documentation**: Run `/update-docs` skill. Every PR must update: + - `docs/edit-history/` session log + - Relevant docs in `docs/` (API, architecture, frontend, etc.) + - `CLAUDE.md` if routes, env vars, or commands changed + - `PLAN.md` if the work completes a phase or adds new items +5. **E2E Test**: Rebuild, restart service, and manually verify before reporting done. +6. **QA Review**: QA Engineer reviews for edge cases, plan compliance, and documentation completeness before CEO merge approval. +7. **CEO Approval**: Only the CEO approves merges. Never merge without explicit approval. + +--- + +## Ecosystem Awareness + +Adjacent projects worth tracking (Holaboss, Hermes, gstack, …) are catalogued +in **[`docs/ecosystem-watch.md`](docs/ecosystem-watch.md)**. Skim quarterly, +add entries liberally, and when one of those projects ships something we +should react to, file a "Signals to react to" line in that doc and create a +Backlog entry below pointing at it. Agents doing research or strategy work +should read `docs/ecosystem-watch.md` first — it's the canonical starting +point for "what else is out there." + +--- + +## Backlog (prioritized) + +1. **Canvas: Org template import** — Phase 20.3 (deploy org from canvas UI) +2. **Canvas: Workspace search (Cmd+K)** — Phase 20.3 (quick find) +3. **Canvas: Batch operations** — Phase 20.3 (multi-select delete/restart) +4. **Sandbox: Firecracker/E2B backends** — Phase 12 (production isolation) +5. **NemoClaw adapter** — stub exists at `adapters/nemoclaw/`, no implementation yet +6. **Remote plugin registry** — install plugins from npm/git (currently local only) +7. **Agent git worktrees** — per-agent branches without full clone +8. **SDK follow-ups** — live tool-call visibility, cost telemetry, cancel UX, governance hooks +9. **Real webhook mode for channels** — Phase 27 candidate. Currently polling-only; webhook needs: + - `mode: "webhook"|"polling"` config field + - `PUBLIC_URL` env var + - Platform calls `setWebhook` on channel create (with random `webhook_secret`), `deleteWebhook` on delete + - Canvas toggle to enable webhook mode (only when PUBLIC_URL is set) + - Polling works fine for ≤hundreds of bots; webhook needed at thousands+ scale or for serverless +10. **More channel adapters** — Slack (OAuth + Events API), Discord (Bot + Gateway), WhatsApp (Cloud API) +11. **Delegations list endpoint mismatch** — #64. `GET /workspaces/:id/delegations` returns `[]` while the agent's internal `check_delegation_status` shows active/completed delegations. One source of truth. +12. **YAML-configurable per-agent repo access** — #65. New `workspace_access: none|read_only|read_write` field in `org.yaml` + `:ro` bind-mount for research agents; eliminates the "PM couriers documents to reports" workaround. +13. **SDK executor swallows subprocess stderr** — #66. `workspace-template/claude_sdk_executor.py` surfaces only "Command failed with exit code 1 / Check stderr output for details" when the `claude` CLI crashes, making every failure opaque. Capture stderr, log at ERROR, include first ~1 KB in the A2A error response. **High priority** — blocked real debugging during PLAN.md coordination on 2026-04-12. +14. **Agent MCP client defaults to `localhost:8080`** — #67. Inside a workspace container, `localhost` is the container itself, not the platform — so `mcp__molecule__*` tools fail with "platform unreachable." Inject `MOLECULE_URL=${PLATFORM_URL}` into every container at provision time and change the MCP client default to `http://host.docker.internal:8080`. **High priority** — blocks agents from calling platform tools (e.g. PM couldn't restart its own reports). + +--- + +## Test Coverage + +| Stack | Tests | Framework | +|-------|-------|-----------| +| Go (platform) | 476 | `go test -race` | +| Python (workspace) | 1,040 | pytest | +| Canvas (frontend) | 352 | Vitest | +| SDK (python) | 87 | pytest | +| **Total** | **1,955** | | + +E2E: 68/68 comprehensive checks passing, 62 API tests. + +--- + +## Team Assignments + +| Agent | Current Focus | +|-------|--------------| +| PM | Sprint coordination, backlog prioritization | +| Dev Lead | Engineering planning, PR review | +| UIUX Designer | UX specs for Phase 20 (DONE — 5 specs delivered) | +| Frontend Engineer | Phase 20.3 remaining items (org import, search, batch) | +| Backend Engineer | Sandbox production backends, API completeness | +| QA Engineer | **Review every PR for docs + plan compliance** | +| DevOps Engineer | CI/CD, Docker image optimization | +| Security Auditor | API key handling, path traversal, auth review | + +--- + +## Next Steps + +1. Frontend Engineer implements remaining Phase 20.3 items (org import from canvas, Cmd+K search) +2. Backend Engineer scopes Firecracker/E2B sandbox backends (Phase 12) +3. QA Engineer reviews PR #52 for docs compliance before merge +4. All agents use `GITHUB_TOKEN` env var to clone repo, branch, and create PRs + +--- + +## Future Work — Plugin Adaptor System + +Landed (see `feat/plugin-adaptor-registry` and `feat/agentskills-compliance`): +per-runtime plugin adaptors, hybrid resolver (registry > plugin-shipped > +raw-drop), `AgentskillsAdaptor` covering rule+skill plugins for all +runtimes, `/plugins?runtime=` filter, `/workspaces/:id/plugins/available` +endpoint, `molecule-plugin` SDK, gemini org parity with molecule-dev, +and **full agentskills.io spec compliance** for all first-party skills +(installable in Claude Code, Cursor, Codex, and ~35 other skill-compatible +tools — see `docs/plugins/agentskills-compat.md`). + +Deferred, not blocking: + +- **Upstream `runtime-adapters/` extension to agentskills.io spec** — + once we've lived with our own per-runtime adapter model for ~month, + propose it as a spec extension to `agentskills/agentskills` so other + tools can share Molecule AI-authored adaptors. +- **Install-from-GitHub-URL flow** — `POST /plugins/install {git_url}` that + clones a repo into the registry, validates the manifest, and runs the + adaptor through a sandbox. Needs signature/version pinning and a review + of the adaptor-execution threat model before shipping. +- **Promote-to-default UI** — today, promoting a community plugin to + "curated" means manually copying its `adapters/.py` into + `workspace-template/plugins_registry//`. Later add a canvas + button + PR template that opens an upstream PR automatically. +- **Plugin packs** — manifest that lists other plugins to bundle + (`superpowers-pack` → install `superpowers-tdd` + `superpowers-debug` + …). + Skip until a real user asks; first-party plugins are small enough to + install individually today. +- **Hot-reload on DeepAgents** — upstream docs say skills/sub-agents are + startup-only; would need platform-level container restart on plugin + file change. Defer until users complain. +- **Atomic split of first-party plugins** — `superpowers` and `ecc` still + ship as multi-skill bundles. Pipeline already supports splitting but + non-urgent. +- **Sub-agent plugins for non-DeepAgents runtimes** — Claude Code / + LangGraph don't have a native sub-agent feature; emulating via + tool-routing is possible but invasive. Defer. +- **Workspace install tracking table** — a `workspace_plugin_installs` + table would let uninstall call the adaptor's `uninstall()` path + reliably. Today uninstall is a `rm -rf /configs/plugins/` which + leaves copied skill dirs behind. Low user impact. +- **Shared org-template `system-prompt.md` via `_shared/`** — DRY molecule-dev + and molecule-worker-gemini. Drift risk; revisit at 3+ orgs. diff --git a/README.md b/README.md new file mode 100644 index 00000000..ac310614 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +
+ +

+ Molecule AI Icon Logo +

+ +

+ + + Molecule AI Text Logo + +

+ +

+ English | 中文 +

+ +

The Org-Native Control Plane For Heterogeneous AI Agent Teams

+ +

+ The world's most powerful governance platform for AI agent teams. +

+ +[![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg)](LICENSE) + +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8?logo=go)](https://golang.org/) +[![Python Version](https://img.shields.io/badge/python-3.11+-3776AB?logo=python)](https://www.python.org/) +[![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js)](https://nextjs.org/) + +

+ Visual Canvas • Runtime Compatibility • Hierarchical Memory • Skill Evolution • Operational Guardrails +

+ +

+ Docs Home • + Quick Start • + Architecture • + Platform API • + Workspace Runtime +

+ +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo) + +
+ +--- + +## The Pitch + +Molecule AI is the most powerful way to govern an AI agent organization in production. + +It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product: + +- one org-native control plane for teams, roles, hierarchy, and lifecycle +- one runtime layer that lets LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw run side by side +- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries +- one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces + +Most teams can build a workflow, a strong single agent, a coding agent, or a custom multi-agent graph. + +Very few teams can run all of that as a governed organization with clear structure, durable memory boundaries, and production operations. + +That is the gap Molecule AI closes. + +## Why Molecule AI Feels Different + +### 1. The node is a role, not a task + +In Molecule AI, a workspace is an organizational role. That role can begin as one agent, later expand into a sub-team, and still keep the same external identity, hierarchy position, memory boundary, and A2A interface. + +### 2. The org chart is the topology + +You do not wire collaboration paths by hand. Hierarchy defines the default communication surface. The structure is not decorative UI. It is part of the operating model. + +### 3. Runtime choice stops being a dead-end decision + +LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime. + +### 4. Memory is treated like infrastructure + +Molecule AI's HMA approach is designed around organizational boundaries, not just “store more context somewhere.” Durable recall, scoped sharing, awareness namespaces, and skill promotion are all part of one coherent system. + +### 5. It comes with a real control plane + +Registry, heartbeats, restart, pause/resume, activity logs, approvals, terminal access, files, traces, bundles, templates, and WebSocket fanout are not afterthoughts. They are first-class parts of the platform. + +## The Category Gap Molecule AI Fills + +| Category | What it does well | Where it breaks | What Molecule AI adds | +|---|---|---|---| +| Workflow builders | Visual task automation | Nodes are tasks, not durable organizational roles | Role-native workspaces, hierarchy, long-lived teams | +| Agent frameworks | Strong runtime semantics | Weak control plane and weak org-level operations | Unified lifecycle, canvas, registry, policies, observability | +| Coding agents | Excellent local execution | Usually not designed as team infrastructure | Workspace abstraction, A2A collaboration, platform ops | +| Custom multi-agent graphs | Full flexibility | Brittle topology and governance sprawl | Standardized operating model without losing runtime freedom | + +## What Makes Molecule AI Defensible + +| Advantage | Why it matters in practice | +|---|---| +| **Role-native workspace abstraction** | Your org structure survives model swaps, framework changes, and team expansion | +| **Fractal team expansion** | A single specialist can become a managed department without breaking upstream integrations | +| **Heterogeneous runtime compatibility** | Different teams can keep their preferred agent architecture while sharing one control plane | +| **HMA + awareness namespaces** | Memory sharing follows hierarchy instead of leaking across the whole system | +| **Skill evolution loop** | Durable successful workflows can graduate from memory into reusable, hot-reloadable skills | +| **WebSocket-first operational UX** | The canvas reflects task state, structure changes, and A2A responses in near real time | +| **Global secrets with local override** | Centralize provider access, then override only where a workspace needs specialized credentials | + +## Runtime Compatibility, Compared + +Molecule AI is not trying to replace the frameworks below. It is the system that makes them easier to run together. + +| Runtime / architecture | Status in current repo | Native strength | What Molecule AI adds | +|---|---|---|---| +| **LangGraph** | Shipping on `main` | Graph control, tool use, Python extensibility | Canvas orchestration, hierarchy routing, A2A, memory scopes, operational lifecycle | +| **DeepAgents** | Shipping on `main` | Deeper planning and decomposition | Same workspace contract, team topology, activity stream, restart behavior | +| **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane | +| **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry | +| **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane | +| **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration | +| **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` | + +This is the key idea: **many agent runtimes, one organizational operating system**. + +## Why The Memory Architecture Compounds + +Most projects stop at “we added memory.” Molecule AI pushes further: + +| Conventional memory setup | Molecule AI | +|---|---| +| Flat store or weak namespaces | Hierarchy-aligned `LOCAL`, `TEAM`, `GLOBAL` scopes | +| Sharing is easy to overexpose | Sharing is explicit and structure-aware | +| Memory and procedure get mixed together | Memory stores durable facts; skills store repeatable procedure | +| Every agent can become over-privileged | Workspace awareness namespaces reduce blast radius | +| UI memory and runtime memory blur together | Separate surfaces for scoped agent memory, key/value workspace memory, and recall | + +### The flywheel + +```text +Task execution + -> durable insight captured in memory + -> repeated success becomes a signal + -> workflow promoted into a reusable skill + -> skill hot-reloads into the runtime + -> future work gets faster and more reliable +``` + +This is one of Molecule AI's strongest long-term advantages: the system can get more operationally capable without turning into one giant hidden prompt. + +## Self-Improving Agent Teams, Built Into Molecule AI + +Most agent systems stop at "a smart runtime." Molecule AI pushes further: it gives teams a way to **capture what worked, promote repeatable procedure into skills, reload those improvements into live workspaces, and keep the whole loop visible at the platform level**. + +| Positioning lens | Conventional self-improving agent pattern | Molecule AI | +|---|---|---| +| **Unit of improvement** | A single agent session or runtime | A workspace, a team, and eventually the whole org graph | +| **Operational surface** | Mostly hidden inside the agent loop | Visible in the platform, Canvas, activity stream, memory surfaces, and runtime controls | +| **Strategic outcome** | A smarter agent | A compounding organization with durable knowledge and governed reusable skills | + +### Where that shows up in Molecule AI + +| Core mechanism | Molecule AI module(s) | Why it matters | +|---|---|---| +| **Durable memory that survives sessions** | `workspace-template/builtin_tools/memory.py`, `workspace-template/builtin_tools/awareness_client.py`, `platform/internal/handlers/memories.go` | Memory is not just durable, it is **workspace-scoped** and can route into awareness namespaces tied to the org structure | +| **Cross-session recall** | `platform/internal/handlers/activity.go` (`/workspaces/:id/session-search`) | Recall spans both activity history and memory rows, so the system can search what happened and what was learned without inventing a separate hidden store | +| **Skills built from experience** | `workspace-template/builtin_tools/memory.py` (`_maybe_log_skill_promotion`) | Promotion from memory into a skill candidate is surfaced as an explicit platform activity, not a silent internal side effect | +| **Skill improvement during use** | `workspace-template/skill_loader/watcher.py`, `workspace-template/skill_loader/loader.py`, `workspace-template/main.py` | Skills hot-reload into the live runtime, so improvements become available on the next A2A task without restarting the workspace | +| **Persistent skill lifecycle** | `platform/cmd/cli/cmd_agent_skill.go`, `workspace-template/plugins.py` | Skills are not just generated once; they can be audited, installed, published, shared, mounted by plugins, and governed as reusable operational assets | + +### Why this matters in Molecule AI + +1. **The learning loop is org-aware, not just session-aware.** + Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and awareness namespaces give each workspace a durable identity boundary. + +2. **The learning loop is visible to operators.** + Promotion events, activity logs, current-task updates, traces, and WebSocket fanout mean self-improvement is part of the control plane, not a hidden black box. + +3. **The learning loop compounds across teams, not just one agent.** + A workflow learned by one workspace can become a governed skill, reload into the runtime, appear in the Agent Card, and become usable inside a larger organizational hierarchy. + +The result is not just “an agent that learns.” It is **an organization that gets more capable as its workspaces accumulate durable memory and reusable procedure**. + +## What Ships In `main` + +### Canvas + +- Next.js 15 + React Flow + Zustand +- drag-to-nest team building +- empty-state deployment + onboarding wizard +- template palette +- bundle import/export +- 10-tab side panel for chat, activity, details, skills, terminal, config, files, memory, traces, and events + +### Platform + +- Go/Gin control plane +- workspace CRUD and provisioning +- registry and heartbeats +- browser-safe A2A proxy +- team expansion/collapse +- activity logs and approvals +- secrets and global secrets +- files API, terminal, bundles, templates, viewport persistence + +### Runtime + +- unified `workspace-template/` image +- adapter-driven execution +- Agent Card registration +- awareness-backed memory integration +- plugin-mounted shared rules/skills +- hot-reloadable local skills +- coordinator-only delegation path + +### Ops + +- Langfuse traces +- current-task reporting +- pause/resume/restart flows +- activity streaming +- runtime tiers +- direct workspace inspection through terminal and files + +## Built For Teams That Need More Than A Demo + +Molecule AI is especially strong when you need to run: + +- AI engineering teams with PM / Dev Lead / QA / Research / Ops roles +- mixed runtime organizations where one team prefers LangGraph and another prefers Claude Code +- long-lived agent organizations that need memory boundaries and reusable procedures +- internal platforms that want to expose agent teams as structured infrastructure, not ad hoc scripts + +## Architecture + +```text +Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis + | | + | +--> Docker provisioner / bundles / templates / secrets + | + +-------------------- shows --------------------> workspaces, teams, tasks, traces, events + +Workspace Runtime (Python image with adapters) + - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw + - Agent Card + A2A server + - heartbeat + activity + awareness-backed memory + - skills + plugins + hot reload +``` + +## Quick Start + +```bash +git clone https://github.com/Molecule-AI/molecule-monorepo.git +cd molecule-monorepo + +./infra/scripts/setup.sh + +cd platform +go run ./cmd/server + +cd ../canvas +npm install +npm run dev +``` + +Then open `http://localhost:3000`: + +1. Deploy a template or create a blank workspace from the empty state. +2. Follow the onboarding guide into `Config`. +3. Add a provider key in `Secrets & API Keys`. +4. Open `Chat` and send the first task. + +## Documentation Map + +- [Docs Home](./docs/index.md) +- [Quick Start](./docs/quickstart.md) +- [Product Overview](./docs/product/overview.md) +- [System Architecture](./docs/architecture/architecture.md) +- [Memory Architecture](./docs/architecture/memory.md) +- [Platform API](./docs/api-protocol/platform-api.md) +- [Workspace Runtime](./docs/agent-runtime/workspace-runtime.md) +- [Canvas UI](./docs/frontend/canvas.md) +- [Local Development](./docs/development/local-development.md) +- [Ecosystem Watch](./docs/ecosystem-watch.md) — adjacent projects we track (Holaboss, Hermes, gstack, …) + +## Current Scope + +The current `main` branch already includes the core platform, canvas, memory model, six production adapters, skill lifecycle, and operational surfaces. Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose. + +## License + +[Business Source License 1.1](LICENSE) — copyright © 2025 Molecule AI. + +Personal, internal, and non-commercial use is permitted without restriction. You may not use the Licensed Work to offer a competing product or service. On January 1, 2029, the license converts to Apache 2.0. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..fafe1655 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,290 @@ +
+ +

+ Molecule AI 图案 Logo +

+ +

+ + + Molecule AI 文字 Logo + +

+ +

+ English | 中文 +

+ +

面向异构 AI Agent 团队的组织级控制平面

+ +

+ 全球最强大的 Agent Team 治理方案。 +

+ +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/go-1.25+-00ADD8?logo=go)](https://golang.org/) +[![Python Version](https://img.shields.io/badge/python-3.11+-3776AB?logo=python)](https://www.python.org/) +[![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js)](https://nextjs.org/) + +

+ Visual Canvas • Runtime Compatibility • Hierarchical Memory • Skill Evolution • Operational Guardrails +

+ +

+ 文档首页 • + 快速开始 • + 系统架构 • + Platform API • + Workspace Runtime +

+ +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo) + +
+ +--- + +## 一句话定位 + +Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 agent 从“能跑的 demo”推进到“可管理、可协作、可运营的生产系统”。 + +它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品: + +- 一套组织原生 control plane,管理团队、角色、层级和生命周期 +- 一套 runtime abstraction,让 LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 并存运行 +- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系 +- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进 + +今天很多团队能做好 workflow、单 agent、coding agent,或者自定义 multi-agent graph 中的一种。 + +但很少有团队能把这些能力一起运行成一个有清晰结构、稳定 memory 边界和生产级运维能力的 Agent 组织。 + +Molecule AI 填的就是这个空白。 + +## 为什么 Molecule AI 很不一样 + +### 1. 节点是角色,不是任务 + +在 Molecule AI 里,workspace 是一个组织角色。这个角色今天可以是单 agent,明天可以扩成内部子团队,而且对外身份、层级位置、memory 边界和 A2A 接口都不变。 + +### 2. 组织图就是拓扑 + +你不需要手动画协作边。层级天然决定默认协作路径。这里的组织结构不是装饰性 UI,而是运行模型本身的一部分。 + +### 3. Runtime 选择不再是死路 + +LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。 + +### 4. Memory 被当成基础设施来做 + +Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、awareness namespace、skill promotion,把这些放在一个完整体系里。 + +### 5. 它自带真正的 control plane + +Registry、heartbeat、restart、pause/resume、activity、approval、terminal、files、traces、bundles、templates、WebSocket fanout 都不是补丁,而是平台一等能力。 + +## Molecule AI 填补了什么市场空白 + +| 类别 | 擅长什么 | 通常卡在哪里 | Molecule AI 补上的部分 | +|---|---|---|---| +| Workflow builder | 可视化任务编排 | 节点是任务,不是持久组织角色 | 角色原生 workspace、层级结构、长期团队 | +| Agent framework | Runtime 语义强 | 缺统一 control plane 和组织级运维 | 生命周期、Canvas、registry、策略、observability | +| Coding agent | 本地执行很强 | 不适合直接当团队基础设施 | Workspace abstraction、A2A 协作、平台化运维 | +| 自定义 multi-agent graph | 灵活度高 | 拓扑脆弱、治理分散 | 在保留 runtime 自由度的同时统一 operating model | + +## Molecule AI 的可防御优势 + +| 优势 | 为什么重要 | +|---|---| +| **角色原生 workspace 抽象** | 模型切换、框架切换、团队扩容都不会打碎你的组织结构 | +| **分形式团队扩展** | 一个 specialist 可以平滑升级成一个部门,而不影响上游集成 | +| **异构 runtime 兼容** | 不同团队可以保留偏好的 agent 架构,但共用一套平台规则 | +| **HMA + awareness namespace** | Memory 分享沿组织边界走,而不是全局乱穿透 | +| **Skill 演化闭环** | 成功工作流可以从 memory 逐步提升成可热加载的 skill | +| **WebSocket-first 运维体验** | Canvas 能即时反映任务状态、结构变更和 A2A 响应 | +| **Global secrets + local override** | 统一管理 provider 凭据,只在需要时做 workspace 级覆写 | + +## 兼容哪些 Agent 架构,怎么对比 + +Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更强的组织级 operating model。 + +| Runtime / 架构 | 当前仓库状态 | 原生优势 | Molecule AI 额外补上的能力 | +|---|---|---|---| +| **LangGraph** | `main` 已支持 | 图控制强、工具调用成熟、Python 扩展性好 | Canvas orchestration、层级路由、A2A、memory scope、operational lifecycle | +| **DeepAgents** | `main` 已支持 | 规划和任务拆解更强 | 同一套 workspace contract、团队拓扑、activity、restart 行为 | +| **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane | +| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry | +| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 | +| **OpenClaw** | `main` 已支持 | CLI-native runtime,自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 | +| **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 | + +核心价值就是:**多种 agent runtime,共用一套组织级操作系统**。 + +## 为什么我们的 Memory 架构会越跑越强 + +很多项目停留在“加了 memory”。Molecule AI 走得更远: + +| 常见 memory 方案 | Molecule AI | +|---|---| +| 扁平 store 或弱命名空间隔离 | 与层级对齐的 `LOCAL`、`TEAM`、`GLOBAL` scope | +| 分享很容易越界 | 分享是显式且结构感知的 | +| Memory 和 procedure 混成一团 | Memory 存 durable facts,skills 存 repeatable procedure | +| 任意 agent 容易过权 | workspace awareness namespace 缩小 blast radius | +| UI memory 和 runtime memory 混在一起 | scoped agent memory、key/value workspace memory、recall surface 分层清晰 | + +### 这套飞轮怎么转 + +```text +任务执行 + -> durable insight 进入 memory + -> 重复成功变成 signal + -> workflow 提升成 skill + -> skill 热加载回 runtime + -> 后续协作更快、更稳 +``` + +这正是 Molecule AI 最强的长期价值之一:系统会越来越像一个组织,而不是越来越像一段越来越大的隐藏 prompt。 + +## Molecule AI 内建的自我进化式 Agent Team 架构 + +很多 agent 系统停留在“runtime 很聪明”。Molecule AI 往前走了一步: 它让团队可以**把有效经验写入 durable memory,把稳定 workflow 提升成 skill,把这些改进热加载回 live workspace,并且让整条闭环在平台层可见、可治理、可复用**。 + +| 对比维度 | 常见自我进化 agent 模式 | Molecule AI | +|---|---|---| +| **进化单元** | 单个 agent session 或 runtime | 一个 workspace、一个团队,最终到整张组织图谱 | +| **运维可见面** | 主要隐藏在 agent 内部循环里 | 可被平台、Canvas、activity stream、memory surface、runtime controls 共同观察和治理 | +| **战略结果** | 一个更聪明的 agent | 一个会持续复利、沉淀 durable knowledge 和 governed skills 的 AI 组织 | + +### 在 Molecule AI 里,这条闭环落在哪些模块 + +| 核心机制 | Molecule AI 对应模块 | 为什么重要 | +|---|---|---| +| **跨 session 的 durable memory** | `workspace-template/builtin_tools/memory.py`、`workspace-template/builtin_tools/awareness_client.py`、`platform/internal/handlers/memories.go` | 不只是持久化,而且是**按 workspace 隔离**的,可进一步路由到和组织结构绑定的 awareness namespace | +| **Cross-session recall** | `platform/internal/handlers/activity.go` 中的 `/workspaces/:id/session-search` | Recall 同时覆盖 activity history 和 memory rows,不需要再造一个隐蔽的新存储层 | +| **从经验里长出技能** | `workspace-template/builtin_tools/memory.py` 里的 `_maybe_log_skill_promotion` | 从 memory 到 skill candidate 的提升会被显式记录成平台 activity,而不是默默发生在黑盒里 | +| **技能在使用中持续改进** | `workspace-template/skill_loader/watcher.py`、`workspace-template/skill_loader/loader.py`、`workspace-template/main.py` | Skill 改动可以热加载进 live runtime,下一次 A2A 任务就能直接使用,不需要重启 workspace | +| **持久化 skill 生命周期** | `platform/cmd/cli/cmd_agent_skill.go`、`workspace-template/plugins.py` | Skill 不只是“生成一次”,而是可以 audit、install、publish、plugin 挂载、治理和复用的正式资产 | + +### 为什么这在 Molecule AI 里更适合团队级系统 + +1. **学习闭环是 org-aware 的,而不只是 session-aware。** + Memory 可以按 `LOCAL`、`TEAM`、`GLOBAL` scope 运作,awareness namespace 让每个 workspace 都有清晰的持久边界。 + +2. **学习闭环是对运维可见的。** + Promotion events、activity logs、current-task updates、traces、WebSocket fanout 让自我进化进入 control plane,而不是藏在黑盒内部。 + +3. **学习闭环是可以跨团队复利的。** + 某个 workspace 学出来的稳定 workflow 可以变成受治理的 skill,热加载回 runtime,写进 Agent Card,并继续服务更大的组织层级。 + +所以 Molecule AI 的目标不只是“一个会学习的 agent”,而是**一个会随着工作沉淀出 durable memory 和 reusable procedure、并持续变强的 AI 组织**。 + +## `main` 分支已经具备什么 + +### Canvas + +- Next.js 15 + React Flow + Zustand +- drag-to-nest 团队构建 +- empty state + onboarding wizard +- template palette +- bundle import/export +- 包含 chat、activity、details、skills、terminal、config、files、memory、traces、events 的 10 个侧栏 tab + +### Platform + +- Go/Gin control plane +- workspace CRUD 和 provisioning +- registry 与 heartbeat +- 浏览器安全的 A2A proxy +- team expansion/collapse +- activity logs 与 approvals +- secrets 和 global secrets +- files API、terminal、bundles、templates、viewport persistence + +### Runtime + +- 统一 `workspace-template/` 镜像 +- adapter 驱动执行 +- Agent Card 注册 +- awareness-backed memory +- plugin 挂载共享 rules/skills +- 本地 skills 热加载 +- coordinator-only delegation 路径 + +### Ops + +- Langfuse traces +- current-task reporting +- pause/resume/restart +- activity streaming +- runtime tiers +- 终端与文件层面的 workspace 直接排障 + +## 适合什么团队 + +Molecule AI 特别适合下面这些场景: + +- 需要 PM / Dev Lead / QA / Research / Ops 等角色协作的 AI 工程团队 +- 不同子团队偏好不同 agent runtime 的组织 +- 需要长期 memory 边界和技能沉淀的 agent 系统 +- 想把 agent team 作为正式基础设施,而不是零散脚本的内部平台团队 + +## 架构总览 + +```text +Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis + | | + | +--> Docker provisioner / bundles / templates / secrets + | + +-------------------- 展示 --------------------> workspaces, teams, tasks, traces, events + +Workspace Runtime (Python image with adapters) + - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw + - Agent Card + A2A server + - heartbeat + activity + awareness-backed memory + - skills + plugins + hot reload +``` + +## 快速开始 + +```bash +git clone https://github.com/Molecule-AI/molecule-monorepo.git +cd molecule-monorepo + +./infra/scripts/setup.sh + +cd platform +go run ./cmd/server + +cd ../canvas +npm install +npm run dev +``` + +然后打开 `http://localhost:3000`: + +1. 在 empty state 中部署模板,或者创建 blank workspace。 +2. 跟着 onboarding guide 进入 `Config`。 +3. 在 `Secrets & API Keys` 中添加 provider key。 +4. 打开 `Chat` 并发送第一条任务。 + +## 文档导航 + +- [文档首页](./docs/index.md) +- [快速开始](./docs/quickstart.md) +- [产品概览](./docs/product/overview.md) +- [系统架构](./docs/architecture/architecture.md) +- [记忆架构](./docs/architecture/memory.md) +- [Platform API](./docs/api-protocol/platform-api.md) +- [Workspace Runtime](./docs/agent-runtime/workspace-runtime.md) +- [Canvas UI](./docs/frontend/canvas.md) +- [本地开发](./docs/development/local-development.md) +- [生态观察](./docs/ecosystem-watch.md) — 值得关注的相邻项目(Holaboss、Hermes、gstack 等) + +## 当前范围说明 + +当前 `main` 已经包含核心平台、Canvas、memory model、6 个正式 adapter、skill lifecycle 和主要运维面。像 **NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。 + +## License + +MIT diff --git a/canvas/Dockerfile b/canvas/Dockerfile new file mode 100644 index 00000000..a920197c --- /dev/null +++ b/canvas/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 +ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws +ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts new file mode 100644 index 00000000..2b263c8b --- /dev/null +++ b/canvas/e2e/chat-separation.spec.ts @@ -0,0 +1,336 @@ +import { test, expect } from "@playwright/test"; + +const API = process.env.E2E_API_URL ?? "http://localhost:8080"; + +test.describe("Chat Sub-Tabs", () => { + test("chat tab shows My Chat and Agent Comms sub-tabs", async ({ page }) => { + const res = await page.request.get(`${API}/workspaces`); + const workspaces = await res.json(); + test.skip(workspaces.length === 0, "No workspaces to test"); + + await page.goto("/"); + await page.waitForTimeout(3000); + + // Click first workspace node + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + + // Click Chat tab + const chatTab = page.getByRole("button", { name: /Chat/ }).first(); + await chatTab.click(); + await page.waitForTimeout(500); + + // Sub-tabs should be visible + await expect(page.locator("text=My Chat")).toBeVisible({ timeout: 3000 }); + await expect(page.locator("text=Agent Comms")).toBeVisible({ timeout: 3000 }); + }); + + test("My Chat is selected by default", async ({ page }) => { + const res = await page.request.get(`${API}/workspaces`); + const workspaces = await res.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + await page.goto("/"); + await page.waitForTimeout(3000); + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(500); + + // My Chat sub-tab should have active styling (border-blue-500) + const myChatBtn = page.locator("button", { hasText: "My Chat" }); + await expect(myChatBtn).toHaveClass(/border-blue-500/); + }); + + test("switching to Agent Comms shows different content", async ({ page }) => { + const res = await page.request.get(`${API}/workspaces`); + const workspaces = await res.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + await page.goto("/"); + await page.waitForTimeout(3000); + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(500); + + // Click Agent Comms + await page.locator("button", { hasText: "Agent Comms" }).click(); + await page.waitForTimeout(500); + + // Should show empty state or agent comms messages + const hasEmpty = await page.locator("text=No agent-to-agent communications").isVisible().catch(() => false); + const hasMessages = await page.locator("[class*=cyan]").count() > 0; + expect(hasEmpty || hasMessages).toBeTruthy(); + }); + + test("My Chat has input box, Agent Comms does not", async ({ page }) => { + const res = await page.request.get(`${API}/workspaces`); + const workspaces = await res.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + await page.goto("/"); + await page.waitForTimeout(3000); + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(500); + + // My Chat should have textarea + await expect(page.locator("textarea")).toBeVisible(); + + // Switch to Agent Comms + await page.locator("button", { hasText: "Agent Comms" }).click(); + await page.waitForTimeout(500); + + // Agent Comms should NOT have textarea + await expect(page.locator("textarea")).not.toBeVisible(); + }); + + test("switching back to My Chat preserves messages", async ({ page }) => { + const res = await page.request.get(`${API}/workspaces`); + const workspaces = await res.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + await page.goto("/"); + await page.waitForTimeout(3000); + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(1000); + + // Check if there are messages or empty state in My Chat + const hasContent = await page.locator("text=No messages yet").isVisible().catch(() => false) || + await page.locator("[class*=blue-600]").count() > 0; + + // Switch to Agent Comms and back + await page.locator("button", { hasText: "Agent Comms" }).click(); + await page.waitForTimeout(300); + await page.locator("button", { hasText: "My Chat" }).click(); + await page.waitForTimeout(300); + + // Same content should be there + const hasContentAfter = await page.locator("text=No messages yet").isVisible().catch(() => false) || + await page.locator("[class*=blue-600]").count() > 0; + + // Both should be truthy (content exists before and after switch) + expect(hasContent || hasContentAfter).toBeTruthy(); + }); +}); + +test.describe("Activity API Source Filter", () => { + test("source=canvas returns only canvas-initiated entries", async ({ request }) => { + const wsRes = await request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + if (workspaces.length === 0) return; + + const wsId = workspaces[0].id; + const res = await request.get(`${API}/workspaces/${wsId}/activity?source=canvas`); + expect(res.ok()).toBeTruthy(); + const entries = await res.json(); + expect(Array.isArray(entries)).toBeTruthy(); + + // All entries should have source_id null + for (const e of entries) { + expect(e.source_id).toBeNull(); + } + }); + + test("source=agent returns only agent-initiated entries", async ({ request }) => { + const wsRes = await request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + if (workspaces.length === 0) return; + + const wsId = workspaces[0].id; + const res = await request.get(`${API}/workspaces/${wsId}/activity?source=agent`); + expect(res.ok()).toBeTruthy(); + const entries = await res.json(); + expect(Array.isArray(entries)).toBeTruthy(); + + // All entries should have non-null source_id + for (const e of entries) { + if (e.source_id !== undefined) { + expect(e.source_id).not.toBeNull(); + } + } + }); + + test("source=invalid returns 400", async ({ request }) => { + const wsRes = await request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + if (workspaces.length === 0) return; + + const wsId = workspaces[0].id; + const res = await request.get(`${API}/workspaces/${wsId}/activity?source=bogus`); + expect(res.status()).toBe(400); + }); + + test("source+type filters combine correctly", async ({ request }) => { + const wsRes = await request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + if (workspaces.length === 0) return; + + const wsId = workspaces[0].id; + const res = await request.get(`${API}/workspaces/${wsId}/activity?type=a2a_receive&source=canvas`); + expect(res.ok()).toBeTruthy(); + const entries = await res.json(); + expect(Array.isArray(entries)).toBeTruthy(); + + for (const e of entries) { + expect(e.activity_type).toBe("a2a_receive"); + expect(e.source_id).toBeNull(); + } + }); +}); + +test.describe("Data Flow — Initial Prompt in Chat", () => { + test("initial prompt appears as user message in My Chat", async ({ page }) => { + // Find a workspace that has activity with source=canvas (initial prompt via proxy) + const wsRes = await page.request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + // Find a workspace with canvas-initiated activity + let targetWs: { id: string; name: string } | null = null; + for (const ws of workspaces) { + const actRes = await page.request.get(`${API}/workspaces/${ws.id}/activity?source=canvas&type=a2a_receive&limit=1`); + const entries = await actRes.json(); + if (entries.length > 0) { + targetWs = ws; + break; + } + } + test.skip(!targetWs, "No workspace has canvas-initiated activity (initial prompt may not have run)"); + + await page.goto("/"); + await page.waitForTimeout(3000); + + // Click the workspace node + const node = page.locator(`.react-flow__node`).filter({ hasText: targetWs!.name }); + await node.first().click(); + await page.waitForTimeout(500); + + // Open Chat tab + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(500); + + // Ensure we're on My Chat + await page.locator("button", { hasText: "My Chat" }).click(); + await page.waitForTimeout(2000); + + // The chat should NOT show "No messages yet" — it should have the initial prompt + const emptyState = page.locator("text=No messages yet"); + await expect(emptyState).not.toBeVisible({ timeout: 5000 }); + + // There should be at least one user message bubble (blue) and one agent message bubble + const userBubbles = page.locator('[class*="bg-blue-600"]'); + const agentBubbles = page.locator('[class*="bg-zinc-800"]'); + expect(await userBubbles.count()).toBeGreaterThan(0); + expect(await agentBubbles.count()).toBeGreaterThan(0); + }); + + test("initial prompt text matches config content", async ({ page }) => { + const wsRes = await page.request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + // Find workspace with activity + let targetWs: { id: string; name: string } | null = null; + let promptText = ""; + for (const ws of workspaces) { + const actRes = await page.request.get(`${API}/workspaces/${ws.id}/activity?source=canvas&type=a2a_receive&limit=1`); + const entries = await actRes.json(); + if (entries.length > 0) { + const reqBody = entries[0].request_body; + const parts = reqBody?.params?.message?.parts; + if (parts?.[0]?.text) { + targetWs = ws; + promptText = parts[0].text; + break; + } + } + } + test.skip(!targetWs, "No workspace has canvas-initiated activity with text"); + + await page.goto("/"); + await page.waitForTimeout(3000); + + const node = page.locator(`.react-flow__node`).filter({ hasText: targetWs!.name }); + await node.first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(500); + await page.locator("button", { hasText: "My Chat" }).click(); + await page.waitForTimeout(2000); + + // The first few words of the initial prompt should be visible in the chat + const firstWords = promptText.split(/\s+/).slice(0, 4).join(" "); + await expect(page.locator(`text=${firstWords}`).first()).toBeVisible({ timeout: 5000 }); + }); + + test("agent response to initial prompt is visible", async ({ page }) => { + const wsRes = await page.request.get(`${API}/workspaces`); + const workspaces = await wsRes.json(); + test.skip(workspaces.length === 0, "No workspaces"); + + let targetWs: { id: string } | null = null; + let responseText = ""; + for (const ws of workspaces) { + const actRes = await page.request.get(`${API}/workspaces/${ws.id}/activity?source=canvas&type=a2a_receive&limit=1`); + const entries = await actRes.json(); + if (entries.length > 0 && entries[0].response_body) { + const result = entries[0].response_body.result; + const parts = result?.parts; + if (parts?.[0]?.text) { + targetWs = ws; + responseText = parts[0].text; + break; + } + } + } + test.skip(!targetWs, "No workspace has response in activity"); + + await page.goto("/"); + await page.waitForTimeout(3000); + await page.locator(".react-flow__node").first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(2000); + + // Agent response should be visible — check for agent message bubble existence + // (response text varies per agent, so check for non-empty agent bubble instead of exact text) + await page.locator("button", { hasText: "My Chat" }).click(); + await page.waitForTimeout(2000); + const agentBubbles = page.locator('[class*="bg-zinc-800"]'); + expect(await agentBubbles.count()).toBeGreaterThan(0); + }); +}); + +test.describe("No JS Errors", () => { + test("page loads without errors with chat sub-tabs", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto("/"); + await page.waitForTimeout(3000); + + const nodes = page.locator(".react-flow__node"); + if (await nodes.count() > 0) { + await nodes.first().click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: /Chat/ }).first().click(); + await page.waitForTimeout(1000); + + // Switch between tabs + await page.locator("button", { hasText: "Agent Comms" }).click(); + await page.waitForTimeout(500); + await page.locator("button", { hasText: "My Chat" }).click(); + await page.waitForTimeout(500); + } + + const critical = errors.filter( + (e) => !e.includes("WebSocket") && !e.includes("favicon") && !e.includes("hydration") + ); + expect(critical).toEqual([]); + }); +}); diff --git a/canvas/e2e/org-template-import.spec.ts b/canvas/e2e/org-template-import.spec.ts new file mode 100644 index 00000000..9bafb08b --- /dev/null +++ b/canvas/e2e/org-template-import.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; + +const API = process.env.E2E_API_URL ?? "http://localhost:8080"; + +test.describe("Org template import (PLAN.md §20.3)", () => { + test("org templates section renders inside the palette", async ({ page }) => { + // Sanity: platform must serve /org/templates + const res = await page.request.get(`${API}/org/templates`); + expect(res.ok()).toBeTruthy(); + const orgs: { dir: string; name: string; workspaces: number }[] = await res.json(); + test.skip(orgs.length === 0, "No org templates configured"); + + await page.goto("/", { waitUntil: "networkidle" }); + + // The Org Templates section lives in TWO places: inside the EmptyState + // (visible only when there are 0 workspaces) and inside the + // TemplatePalette sidebar. Open the palette so the section is reachable + // regardless of workspace count. + const paletteToggle = page.getByTitle("Template Palette"); + if (await paletteToggle.isVisible()) { + await paletteToggle.click({ force: true }); + } + + const section = page.getByTestId("org-templates-section").first(); + await expect(section).toBeVisible({ timeout: 15000 }); + await expect(section.getByText("Org Templates")).toBeVisible(); + + // Wait for the API fetch to populate (auto-waits via toBeVisible) + const first = orgs[0]; + const label = first.name || first.dir; + await expect(section.getByText(label, { exact: false })).toBeVisible({ timeout: 15000 }); + await expect(section.getByText(`${first.workspaces}w`)).toBeVisible(); + await expect(section.getByRole("button", { name: /Import org/i }).first()).toBeVisible(); + }); + + test("import button exists for every org template returned by the API", async ({ page }) => { + const res = await page.request.get(`${API}/org/templates`); + const orgs: { dir: string }[] = await res.json(); + test.skip(orgs.length === 0, "No org templates configured"); + + await page.goto("/", { waitUntil: "networkidle" }); + const paletteToggle = page.getByTitle("Template Palette"); + if (await paletteToggle.isVisible()) { + await paletteToggle.click({ force: true }); + } + const section = page.getByTestId("org-templates-section").first(); + await expect(section).toBeVisible({ timeout: 15000 }); + // Wait for the API result to render (one Import button per org) + const buttons = section.getByRole("button", { name: /Import org/i }); + await expect(buttons).toHaveCount(orgs.length, { timeout: 15000 }); + }); +}); diff --git a/canvas/next.config.ts b/canvas/next.config.ts new file mode 100644 index 00000000..68a6c64d --- /dev/null +++ b/canvas/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/canvas/package-lock.json b/canvas/package-lock.json new file mode 100644 index 00000000..125a356a --- /dev/null +++ b/canvas/package-lock.json @@ -0,0 +1,5309 @@ +{ + "name": "molecule-monorepo-canvas", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "molecule-monorepo-canvas", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.1.14", + "@tailwindcss/typography": "^0.5.19", + "@xterm/addon-fit": "^0.11.0", + "@xyflow/react": "^12.4.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "xterm": "^5.3.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "vitest": "^4.1.2" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.14", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.14", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz", + "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz", + "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz", + "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz", + "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz", + "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz", + "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.14", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz", + "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "license": "MIT" + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.14", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.14", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.14", + "@next/swc-darwin-x64": "15.5.14", + "@next/swc-linux-arm64-gnu": "15.5.14", + "@next/swc-linux-arm64-musl": "15.5.14", + "@next/swc-linux-x64-gnu": "15.5.14", + "@next/swc-linux-x64-musl": "15.5.14", + "@next/swc-win32-arm64-msvc": "15.5.14", + "@next/swc-win32-x64-msvc": "15.5.14", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xterm": { + "version": "5.3.0", + "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.12", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/canvas/package.json b/canvas/package.json new file mode 100644 index 00000000..77e9cf76 --- /dev/null +++ b/canvas/package.json @@ -0,0 +1,42 @@ +{ + "name": "molecule-monorepo-canvas", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-tooltip": "^1.1.14", + "@tailwindcss/typography": "^0.5.19", + "@xterm/addon-fit": "^0.11.0", + "@xyflow/react": "^12.4.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "xterm": "^5.3.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "vitest": "^4.1.2" + } +} diff --git a/canvas/playwright.config.ts b/canvas/playwright.config.ts new file mode 100644 index 00000000..a171edae --- /dev/null +++ b/canvas/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: 0, + use: { + baseURL: "http://localhost:3000", + headless: true, + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + ], +}); diff --git a/canvas/postcss.config.js b/canvas/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/canvas/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/canvas/public/.gitkeep b/canvas/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/canvas/public/molecule-icon.png b/canvas/public/molecule-icon.png new file mode 100644 index 00000000..54e53730 Binary files /dev/null and b/canvas/public/molecule-icon.png differ diff --git a/canvas/src/app/__tests__/page-hydration.test.ts b/canvas/src/app/__tests__/page-hydration.test.ts new file mode 100644 index 00000000..02841ce1 --- /dev/null +++ b/canvas/src/app/__tests__/page-hydration.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; + +// --------------------------------------------------------------------------- +// Tests for the hydrateCanvas() function in src/lib/hydrate.ts. +// These tests import and exercise the REAL function, not a reimplementation. +// --------------------------------------------------------------------------- + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// Mock canvas store — hydrateCanvas calls useCanvasStore.getState() +const mockHydrate = vi.fn(); +const mockSetViewport = vi.fn(); +vi.mock("@/store/canvas", () => ({ + useCanvasStore: { + getState: () => ({ + hydrate: mockHydrate, + setViewport: mockSetViewport, + }), + }, +})); + +// Import the REAL function under test +import { hydrateCanvas } from "@/lib/hydrate"; +import { PLATFORM_URL } from "@/lib/api"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockSuccess(body: unknown) { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response); +} + +function mockNetworkError(message = "ECONNREFUSED") { + mockFetch.mockRejectedValueOnce(new Error(message)); +} + +// Speed up tests by mocking setTimeout +vi.useFakeTimers(); + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Tests — success path +// --------------------------------------------------------------------------- + +describe("hydrateCanvas — success path", () => { + it("returns no error and hydrates store when both fetches succeed", async () => { + mockSuccess([{ id: "ws-1" }]); + mockSuccess({ x: 0, y: 0, zoom: 1 }); + + const result = await hydrateCanvas(); + + expect(result.error).toBeNull(); + expect(mockHydrate).toHaveBeenCalledWith([{ id: "ws-1" }]); + expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 1 }); + }); + + it("succeeds when viewport fetch fails (viewport is optional)", async () => { + mockSuccess([{ id: "ws-1" }]); + mockNetworkError("viewport not found"); + + const result = await hydrateCanvas(); + + expect(result.error).toBeNull(); + expect(mockHydrate).toHaveBeenCalledWith([{ id: "ws-1" }]); + expect(mockSetViewport).not.toHaveBeenCalled(); + }); + + it("skips setViewport when viewport is null", async () => { + mockSuccess([{ id: "ws-1" }]); + mockSuccess(null); + + const result = await hydrateCanvas(); + + expect(result.error).toBeNull(); + expect(mockHydrate).toHaveBeenCalledWith([{ id: "ws-1" }]); + expect(mockSetViewport).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — error & retry path +// --------------------------------------------------------------------------- + +describe("hydrateCanvas — error and retry", () => { + it("returns error message after all 3 retries are exhausted", async () => { + // All 3 attempts fail + mockNetworkError("fail 1"); + mockNetworkError("fail 2"); + mockNetworkError("fail 3"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise = hydrateCanvas(); + + // Advance through the two retry delays (1s, 2s) + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + const result = await promise; + spy.mockRestore(); + + expect(result.error).toBe( + `Unable to connect to platform at ${PLATFORM_URL}. Check that the platform is running.` + ); + expect(mockHydrate).not.toHaveBeenCalled(); + }); + + it("error message includes the PLATFORM_URL", async () => { + mockNetworkError("err1"); + mockNetworkError("err2"); + mockNetworkError("err3"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise = hydrateCanvas(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + spy.mockRestore(); + + expect(result.error).toContain(PLATFORM_URL); + expect(result.error).toContain("Check that the platform is running"); + }); + + it("succeeds on retry (fails first, succeeds on attempt 2)", async () => { + // Attempt 1: workspaces fails, viewport also consumed by Promise.all + mockNetworkError("offline"); + mockSuccess(null); // viewport mock for attempt 1 (consumed but irrelevant) + // Attempt 2: both succeed + mockSuccess([{ id: "ws-2" }]); + mockSuccess({ x: 5, y: 5, zoom: 2 }); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise = hydrateCanvas(); + + // Advance past the first retry delay (1s) + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + spy.mockRestore(); + + expect(result.error).toBeNull(); + expect(mockHydrate).toHaveBeenCalledWith([{ id: "ws-2" }]); + expect(mockSetViewport).toHaveBeenCalledWith({ x: 5, y: 5, zoom: 2 }); + }); + + it("calls onRetrying callback before each retry", async () => { + mockNetworkError("fail 1"); + mockNetworkError("fail 2"); + mockNetworkError("fail 3"); + + const onRetrying = vi.fn(); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise = hydrateCanvas(onRetrying); + + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + + await promise; + spy.mockRestore(); + + // onRetrying should be called before attempt 2 and attempt 3 + expect(onRetrying).toHaveBeenCalledTimes(2); + expect(onRetrying).toHaveBeenCalledWith(1); + expect(onRetrying).toHaveBeenCalledWith(2); + }); + + it("logs to console.error on each failed attempt", async () => { + mockNetworkError("err1"); + mockNetworkError("err2"); + mockNetworkError("err3"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise = hydrateCanvas(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + await promise; + + // 3 failed attempts = 3 console.error calls + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledWith("Initial hydration failed:", expect.any(Error)); + spy.mockRestore(); + }); + + it("manual retry: calling hydrateCanvas again after failure succeeds", async () => { + // All 3 retries fail + mockNetworkError("f1"); + mockNetworkError("f2"); + mockNetworkError("f3"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const promise1 = hydrateCanvas(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + const firstResult = await promise1; + expect(firstResult.error).not.toBeNull(); + + // Manual retry — succeeds on first attempt + mockSuccess([]); + mockSuccess(null); + const retryResult = await hydrateCanvas(); + spy.mockRestore(); + + expect(retryResult.error).toBeNull(); + expect(mockHydrate).toHaveBeenCalledWith([]); + }); +}); diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css new file mode 100644 index 00000000..f7b097eb --- /dev/null +++ b/canvas/src/app/globals.css @@ -0,0 +1,112 @@ +@import "xterm/css/xterm.css"; +@import "../styles/settings-panel.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + padding: 0; + overflow: hidden; + background: #09090b; + color: #e4e4e7; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* React Flow overrides for dark theme */ +.react-flow__edge-path { + stroke: #3f3f46 !important; + stroke-width: 1.5 !important; +} + +.react-flow__edge.animated .react-flow__edge-path { + stroke-dasharray: 5; + animation: dashdraw 0.5s linear infinite; +} + +@keyframes dashdraw { + to { + stroke-dashoffset: -10; + } +} + +.react-flow__handle { + transition: all 0.15s ease; +} + +.react-flow__node { + transition: box-shadow 0.2s ease; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #3f3f46; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #52525b; +} + +/* Selection */ +::selection { + background: rgba(59, 130, 246, 0.3); +} + +/* Panel slide animation */ +@keyframes slide-in-from-right { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +@keyframes slide-in-from-bottom { + from { transform: translateY(10px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.slide-in-from-bottom { + animation-name: slide-in-from-bottom; +} + +@keyframes slide-in-from-top { + from { transform: translateY(-10px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.slide-in-from-top { + animation-name: slide-in-from-top; +} + +.animate-in { + animation-fill-mode: both; +} + +.slide-in-from-right { + animation-name: slide-in-from-right; +} + +.duration-200 { + animation-duration: 200ms; +} + +/* Subtle node entrance */ +@keyframes node-appear { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +.react-flow__node { + animation: node-appear 0.3s ease-out; +} diff --git a/canvas/src/app/icon.png b/canvas/src/app/icon.png new file mode 100644 index 00000000..54e53730 Binary files /dev/null and b/canvas/src/app/icon.png differ diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx new file mode 100644 index 00000000..e0e98f1d --- /dev/null +++ b/canvas/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Molecule AI", + description: "AI Org Chart Canvas", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx new file mode 100644 index 00000000..e785cb9a --- /dev/null +++ b/canvas/src/app/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; +import { Canvas } from "@/components/Canvas"; +import { Legend } from "@/components/Legend"; +import { CommunicationOverlay } from "@/components/CommunicationOverlay"; +import { connectSocket, disconnectSocket } from "@/store/socket"; +import { useCanvasStore } from "@/store/canvas"; +import { api } from "@/lib/api"; +import type { WorkspaceData } from "@/store/socket"; + +export default function Home() { + useEffect(() => { + connectSocket(); + + // Hydrate workspaces and restore viewport in parallel + Promise.all([ + api.get("/workspaces"), + api.get<{ x: number; y: number; zoom: number }>("/canvas/viewport").catch(() => null), + ]).then(([workspaces, viewport]) => { + useCanvasStore.getState().hydrate(workspaces); + if (viewport) { + useCanvasStore.getState().setViewport(viewport); + } + }).catch((err) => { + // Initial hydration failed — socket reconnect will retry + console.error("Canvas: initial hydration failed", err); + }); + + return () => { + disconnectSocket(); + }; + }, []); + + return ( + <> + + + + + ); +} diff --git a/canvas/src/components/ApprovalBanner.tsx b/canvas/src/components/ApprovalBanner.tsx new file mode 100644 index 00000000..152f7997 --- /dev/null +++ b/canvas/src/components/ApprovalBanner.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { showToast } from "./Toaster"; + +interface PendingApproval { + id: string; + workspace_id: string; + workspace_name: string; + action: string; + reason: string | null; + status: string; + created_at: string; +} + +export function ApprovalBanner() { + const [approvals, setApprovals] = useState([]); + + // Single endpoint — no N+1 per-workspace polling + const pollApprovals = useCallback(async () => { + try { + const res = await api.get("/approvals/pending"); + setApprovals(res); + } catch { + // Table may not exist yet, or no pending approvals + setApprovals([]); + } + }, []); + + useEffect(() => { + pollApprovals(); + const interval = setInterval(pollApprovals, 10000); + return () => clearInterval(interval); + }, [pollApprovals]); + + const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => { + try { + await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, { + decision, + decided_by: "human", + }); + showToast(decision === "approved" ? "Approved" : "Denied", decision === "approved" ? "success" : "info"); + setApprovals((prev) => prev.filter((a) => a.id !== approval.id)); + } catch { + showToast("Failed to submit decision", "error"); + } + }; + + if (approvals.length === 0) return null; + + return ( +
+ {approvals.map((approval) => ( +
+
+
+ +
+
+
{approval.workspace_name} needs approval
+
{approval.action}
+ {approval.reason && ( +
{approval.reason}
+ )} +
+ + +
+
+
+
+ ))} +
+ ); +} diff --git a/canvas/src/components/BundleDropZone.tsx b/canvas/src/components/BundleDropZone.tsx new file mode 100644 index 00000000..febfdc08 --- /dev/null +++ b/canvas/src/components/BundleDropZone.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { api } from "@/lib/api"; + +export function BundleDropZone() { + const [isDragging, setIsDragging] = useState(false); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState<{ status: string; name?: string } | null>(null); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes("Files")) { + setIsDragging(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const file = Array.from(e.dataTransfer.files).find( + (f) => f.name.endsWith(".bundle.json") + ); + + if (!file) { + setResult({ status: "error", name: "Only .bundle.json files are accepted" }); + setTimeout(() => setResult(null), 3000); + return; + } + + setImporting(true); + try { + const text = await file.text(); + const bundle = JSON.parse(text); + const res = await api.post<{ workspace_id: string; name: string; status: string }>( + "/bundles/import", + bundle + ); + setResult({ status: "success", name: res.name || bundle.name }); + setTimeout(() => setResult(null), 4000); + } catch (e) { + setResult({ + status: "error", + name: e instanceof Error ? e.message : "Import failed", + }); + setTimeout(() => setResult(null), 4000); + } finally { + setImporting(false); + } + }, []); + + return ( + <> + {/* Invisible drop zone covering the canvas */} +
+ + {/* Global drag listener to detect file entering the window */} +
+ + {/* Visual overlay when dragging */} + {isDragging && ( +
+
+
📦
+
Drop Bundle to Import
+
.bundle.json files only
+
+
+ )} + + {/* Importing spinner */} + {importing && ( +
+
+ Importing bundle... +
+ )} + + {/* Result toast */} + {result && ( +
+ {result.status === "success" + ? `Imported "${result.name}" successfully` + : result.name} +
+ )} + + ); +} diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx new file mode 100644 index 00000000..43b369ac --- /dev/null +++ b/canvas/src/components/Canvas.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useRef, useMemo, useEffect, useState } from "react"; +import { + ReactFlow, + ReactFlowProvider, + Background, + Controls, + MiniMap, + useReactFlow, + type OnNodeDrag, + type Node, + type Edge, + BackgroundVariant, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { WorkspaceNode } from "./WorkspaceNode"; +import { SidePanel } from "./SidePanel"; +import { CreateWorkspaceButton } from "./CreateWorkspaceDialog"; +import { ContextMenu } from "./ContextMenu"; +import { TemplatePalette } from "./TemplatePalette"; +import { ApprovalBanner } from "./ApprovalBanner"; +import { BundleDropZone } from "./BundleDropZone"; +import { EmptyState } from "./EmptyState"; +import { OnboardingWizard } from "./OnboardingWizard"; +import { SearchDialog } from "./SearchDialog"; +import { Toaster } from "./Toaster"; +import { Toolbar } from "./Toolbar"; +import { ConfirmDialog } from "./ConfirmDialog"; +// Phase 20 components +import { SettingsPanel, DeleteConfirmDialog } from "./settings"; +// import { ProvisioningTimeout } from "./ProvisioningTimeout"; + +const nodeTypes = { + workspaceNode: WorkspaceNode, +}; + +const defaultEdgeOptions: Partial = { + animated: true, + style: { + stroke: "#3f3f46", + strokeWidth: 1.5, + }, +}; + +export function Canvas() { + return ( + + + + ); +} + +function CanvasInner() { + const nodes = useCanvasStore((s) => s.nodes); + const edges = useCanvasStore((s) => s.edges); + const onNodesChange = useCanvasStore((s) => s.onNodesChange); + const savePosition = useCanvasStore((s) => s.savePosition); + const selectNode = useCanvasStore((s) => s.selectNode); + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const setDragOverNode = useCanvasStore((s) => s.setDragOverNode); + const nestNode = useCanvasStore((s) => s.nestNode); + const isDescendant = useCanvasStore((s) => s.isDescendant); + const dragStartParentRef = useRef(null); + const { getIntersectingNodes } = useReactFlow(); + + const onNodeDragStart: OnNodeDrag> = useCallback( + (_event, node) => { + dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId; + }, + [] + ); + + const onNodeDrag: OnNodeDrag> = useCallback( + (_event, node) => { + const intersecting = getIntersectingNodes(node); + const target = intersecting.find( + (n) => n.id !== node.id && !isDescendant(node.id, n.id) + ); + setDragOverNode(target?.id ?? null); + }, + [getIntersectingNodes, isDescendant, setDragOverNode] + ); + + // Confirmation dialog state for structure changes + const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null); + + const onNodeDragStop: OnNodeDrag> = useCallback( + (_event, node) => { + const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState(); + setDragOverNode(null); + + const nodeName = (node.data as WorkspaceNodeData).name; + + if (dragOverNodeId) { + const targetNode = allNodes.find((n) => n.id === dragOverNodeId); + const targetName = targetNode?.data.name || "Unknown"; + setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName }); + } else { + const currentParentId = (node.data as WorkspaceNodeData).parentId; + if (currentParentId) { + const parentNode = allNodes.find((n) => n.id === currentParentId); + const parentName = parentNode?.data.name || "Unknown"; + setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName }); + } + } + + savePosition(node.id, node.position.x, node.position.y); + }, + [savePosition, setDragOverNode] + ); + + const confirmNest = useCallback(() => { + if (pendingNest) { + nestNode(pendingNest.nodeId, pendingNest.targetId); + setPendingNest(null); + } + }, [pendingNest, nestNode]); + + const cancelNest = useCallback(() => { + setPendingNest(null); + }, []); + + const onPaneClick = useCallback(() => { + selectNode(null); + useCanvasStore.getState().closeContextMenu(); + }, [selectNode]); + + // Team zoom-in: double-click a team node to zoom to its children + const { fitBounds, setCenter } = useReactFlow(); + + // Pan to newly deployed workspace + const panTimerRef = useRef>(undefined); + useEffect(() => { + const handler = (e: Event) => { + const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail; + // Small delay so ReactFlow has time to lay out the node + clearTimeout(panTimerRef.current); + panTimerRef.current = setTimeout(() => { + const node = useCanvasStore.getState().nodes.find((n) => n.id === nodeId); + if (node) { + setCenter(node.position.x + 130, node.position.y + 60, { zoom: 1, duration: 500 }); + } + }, 100); + }; + window.addEventListener("molecule:pan-to-node", handler); + return () => { + window.removeEventListener("molecule:pan-to-node", handler); + clearTimeout(panTimerRef.current); + }; + }, [setCenter]); + useEffect(() => { + const handler = (e: Event) => { + const { nodeId } = (e as CustomEvent).detail; + const state = useCanvasStore.getState(); + const children = state.nodes.filter((n) => n.data.parentId === nodeId); + if (children.length === 0) return; + + const parent = state.nodes.find((n) => n.id === nodeId); + const allNodes = parent ? [parent, ...children] : children; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of allNodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + 260); + maxY = Math.max(maxY, n.position.y + 120); + } + + fitBounds( + { x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 }, + { padding: 0.2, duration: 500 } + ); + }; + window.addEventListener("molecule:zoom-to-team", handler); + return () => window.removeEventListener("molecule:zoom-to-team", handler); + }, [fitBounds]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + const state = useCanvasStore.getState(); + if (state.contextMenu) { + state.closeContextMenu(); + } else if (state.selectedNodeId) { + state.selectNode(null); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + const saveViewport = useCanvasStore((s) => s.saveViewport); + const viewport = useCanvasStore((s) => s.viewport); + const saveTimerRef = useRef>(undefined); + + // Cleanup debounced save timer on unmount + useEffect(() => { + return () => clearTimeout(saveTimerRef.current); + }, []); + + const onMoveEnd = useCallback( + (_event: unknown, vp: { x: number; y: number; zoom: number }) => { + // Debounce viewport saves to avoid spamming the API + clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + saveViewport(vp.x, vp.y, vp.zoom); + }, 1000); + }, + [saveViewport] + ); + + const defaultViewport = useMemo( + () => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }), + // Only use the initial viewport — don't re-render on every save + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // Determine which workspace ID to use for global settings. + // Fall back to "global" when no specific node is selected. + const settingsWorkspaceId = selectedNodeId ?? "global"; + + return ( +
+ + + + { + const status = (node.data as Record)?.status; + switch (status) { + case "online": + return "#34d399"; + case "offline": + return "#52525b"; + case "degraded": + return "#fbbf24"; + case "failed": + return "#f87171"; + case "provisioning": + return "#38bdf8"; + default: + return "#3f3f46"; + } + }} + nodeStrokeWidth={0} + nodeBorderRadius={4} + /> + + + {nodes.length === 0 && } + + + + + + + + + + {/* */} + {!selectedNodeId && } + + {/* Confirmation dialog for structure changes */} + + + {/* Settings Panel — global secrets management drawer */} + + +
+ ); +} diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx new file mode 100644 index 00000000..6f37b4c8 --- /dev/null +++ b/canvas/src/components/CommunicationOverlay.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useCanvasStore } from "@/store/canvas"; +import { api } from "@/lib/api"; + +interface Communication { + id: string; + sourceId: string; + targetId: string; + sourceName: string; + targetName: string; + type: "a2a_send" | "a2a_receive" | "task_update"; + summary: string; + status: string; + timestamp: string; + durationMs: number | null; +} + +/** + * Overlay showing recent A2A communications between workspaces. + * Renders as a floating log panel that auto-updates. + */ +export function CommunicationOverlay() { + const [comms, setComms] = useState([]); + const [visible, setVisible] = useState(true); + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const nodes = useCanvasStore((s) => s.nodes); + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + + const fetchComms = useCallback(async () => { + try { + // Fetch activity from all online workspaces + const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online"); + const allComms: Communication[] = []; + + for (const node of onlineNodes.slice(0, 6)) { + try { + const activities = await api.get>(`/workspaces/${node.id}/activity?limit=5`); + + for (const a of activities) { + if (a.activity_type === "a2a_send" || a.activity_type === "a2a_receive") { + const sourceNode = nodes.find((n) => n.id === (a.source_id || a.workspace_id)); + const targetNode = nodes.find((n) => n.id === (a.target_id || "")); + allComms.push({ + id: a.id, + sourceId: a.source_id || a.workspace_id, + targetId: a.target_id || "", + sourceName: sourceNode?.data.name || "Unknown", + targetName: targetNode?.data.name || "Unknown", + type: a.activity_type as Communication["type"], + summary: a.summary || "", + status: a.status, + timestamp: a.created_at, + durationMs: a.duration_ms, + }); + } + } + } catch { + // Skip workspaces that fail + } + } + + // Sort by timestamp, newest first, dedupe + const seen = new Set(); + const sorted = allComms + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) + .slice(0, 20); + + setComms(sorted); + } catch { + // Silently handle API errors + } + }, []); + + useEffect(() => { + fetchComms(); + const interval = setInterval(fetchComms, 10000); + return () => clearInterval(interval); + }, [fetchComms]); + + if (!visible || comms.length === 0) { + return ( + + ); + } + + return ( +
+
+
+ ↗↙ Communications ({comms.length}) +
+ +
+ +
+ {comms.map((c) => { + const isSelected = selectedNodeId === c.sourceId || selectedNodeId === c.targetId; + const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-blue-400" : "text-amber-400"; + const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆"; + const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱"; + const statusColor = c.status === "ok" ? "text-emerald-400" : c.status === "error" ? "text-red-400" : "text-amber-400"; + const age = formatAge(c.timestamp); + + return ( +
+
+
+ {typeIcon} + + {c.sourceName} + + + {c.targetName} +
+
+ {statusIcon} + {age} +
+
+ {c.summary && ( +
{c.summary}
+ )} + {c.durationMs && ( +
{c.durationMs}ms
+ )} +
+ ); + })} +
+
+ ); +} + +function formatAge(timestamp: string): string { + const diff = Date.now() - new Date(timestamp).getTime(); + if (diff < 60000) return `${Math.floor(diff / 1000)}s`; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`; + return `${Math.floor(diff / 86400000)}d`; +} diff --git a/canvas/src/components/ConfirmDialog.tsx b/canvas/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..64c6d58a --- /dev/null +++ b/canvas/src/components/ConfirmDialog.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + confirmVariant?: "danger" | "primary" | "warning"; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Confirm", + confirmVariant = "primary", + onConfirm, + onCancel, +}: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + // Refs avoid re-binding the keydown handler on every parent render + const onConfirmRef = useRef(onConfirm); + const onCancelRef = useRef(onCancel); + onConfirmRef.current = onConfirm; + onCancelRef.current = onCancel; + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancelRef.current(); + if (e.key === "Enter") onConfirmRef.current(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open]); + + if (!open || !mounted) return null; + + const confirmColors = + confirmVariant === "danger" + ? "bg-red-600 hover:bg-red-500 text-white" + : confirmVariant === "warning" + ? "bg-amber-600 hover:bg-amber-500 text-white" + : "bg-blue-600 hover:bg-blue-500 text-white"; + + // Render via Portal so the fixed-position dialog escapes any containing block + // (e.g. parents with transform, filter, will-change that break position:fixed). + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+

{title}

+

{message}

+
+ +
+ + +
+
+
, + document.body + ); +} diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx new file mode 100644 index 00000000..ede1f418 --- /dev/null +++ b/canvas/src/components/ContextMenu.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { api } from "@/lib/api"; +import { showToast } from "./Toaster"; +import { ConfirmDialog } from "./ConfirmDialog"; + +interface MenuItem { + label: string; + icon: string; + action: () => void; + danger?: boolean; + disabled?: boolean; + divider?: boolean; +} + +export function ContextMenu() { + const contextMenu = useCanvasStore((s) => s.contextMenu); + const closeContextMenu = useCanvasStore((s) => s.closeContextMenu); + const removeNode = useCanvasStore((s) => s.removeNode); + const updateNodeData = useCanvasStore((s) => s.updateNodeData); + const selectNode = useCanvasStore((s) => s.selectNode); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); + const nestNode = useCanvasStore((s) => s.nestNode); + const contextNodeId = contextMenu?.nodeId ?? null; + const hasChildren = useCanvasStore((s) => contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false); + const ref = useRef(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + + // Clear orphaned dialog state when context menu closes + useEffect(() => { + if (!contextMenu) setDeleteConfirm(null); + }, [contextMenu]); + + // Close on click outside or Escape + useEffect(() => { + if (!contextMenu) return; + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as HTMLElement)) { + closeContextMenu(); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closeContextMenu(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [contextMenu, closeContextMenu]); + + const handleExportBundle = useCallback(async () => { + if (!contextMenu || actionLoading) return; + setActionLoading(true); + try { + const bundle = await api.get>(`/bundles/export/${contextMenu.nodeId}`); + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${(contextMenu.nodeData.name || "workspace").toLowerCase().replace(/\s+/g, "-")}.bundle.json`; + a.click(); + URL.revokeObjectURL(url); + showToast("Bundle exported", "success"); + } catch (e) { + showToast("Export failed", "error"); + } finally { + setActionLoading(false); + } + closeContextMenu(); + }, [contextMenu, closeContextMenu, actionLoading]); + + const handleDuplicate = useCallback(async () => { + if (!contextMenu || actionLoading) return; + setActionLoading(true); + try { + const bundle = await api.get>(`/bundles/export/${contextMenu.nodeId}`); + await api.post("/bundles/import", bundle); + } catch (e) { + showToast("Duplicate failed", "error"); + } finally { + setActionLoading(false); + } + closeContextMenu(); + }, [contextMenu, closeContextMenu, actionLoading]); + + const handleRestart = useCallback(async () => { + if (!contextMenu) return; + try { + await api.post(`/workspaces/${contextMenu.nodeId}/restart`, {}); + updateNodeData(contextMenu.nodeId, { status: "provisioning" }); + } catch (e) { + showToast("Restart failed", "error"); + } + closeContextMenu(); + }, [contextMenu, updateNodeData, closeContextMenu]); + + const handlePause = useCallback(async () => { + if (!contextMenu) return; + const nodeId = contextMenu.nodeId; + closeContextMenu(); + try { + await api.post(`/workspaces/${nodeId}/pause`, {}); + updateNodeData(nodeId, { status: "paused" }); + } catch (e) { + showToast("Pause failed", "error"); + } + }, [contextMenu, updateNodeData, closeContextMenu]); + + const handleResume = useCallback(async () => { + if (!contextMenu) return; + const nodeId = contextMenu.nodeId; + closeContextMenu(); + try { + await api.post(`/workspaces/${nodeId}/resume`, {}); + updateNodeData(nodeId, { status: "provisioning" }); + } catch (e) { + showToast("Resume failed", "error"); + } + }, [contextMenu, updateNodeData, closeContextMenu]); + + const handleDelete = useCallback(() => { + if (!contextMenu) return; + // Don't close context menu yet — keep it mounted so ConfirmDialog renders + setDeleteConfirm({ id: contextMenu.nodeId, name: contextMenu.nodeData.name }); + }, [contextMenu]); + + const confirmDelete = useCallback(async () => { + if (!deleteConfirm) return; + try { + await api.del(`/workspaces/${deleteConfirm.id}`); + removeNode(deleteConfirm.id); + } catch { + showToast("Delete failed", "error"); + } + setDeleteConfirm(null); + closeContextMenu(); + }, [deleteConfirm, removeNode, closeContextMenu]); + + const handleViewDetails = useCallback(() => { + if (!contextMenu) return; + selectNode(contextMenu.nodeId); + setPanelTab("details"); + closeContextMenu(); + }, [contextMenu, selectNode, setPanelTab, closeContextMenu]); + + const handleOpenChat = useCallback(() => { + if (!contextMenu) return; + selectNode(contextMenu.nodeId); + setPanelTab("chat"); + closeContextMenu(); + }, [contextMenu, selectNode, setPanelTab, closeContextMenu]); + + const handleOpenTerminal = useCallback(() => { + if (!contextMenu) return; + selectNode(contextMenu.nodeId); + setPanelTab("terminal"); + closeContextMenu(); + }, [contextMenu, selectNode, setPanelTab, closeContextMenu]); + + const handleExpand = useCallback(async () => { + if (!contextMenu) return; + try { + await api.post(`/workspaces/${contextMenu.nodeId}/expand`, {}); + } catch (e) { + showToast("Expand failed", "error"); + } + closeContextMenu(); + }, [contextMenu, closeContextMenu]); + + const handleCollapse = useCallback(async () => { + if (!contextMenu) return; + try { + await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {}); + } catch (e) { + showToast("Collapse failed", "error"); + } + closeContextMenu(); + }, [contextMenu, closeContextMenu]); + + const handleRemoveFromTeam = useCallback(async () => { + if (!contextMenu) return; + try { + await nestNode(contextMenu.nodeId, null); + showToast("Extracted from team", "success"); + } catch { + showToast("Extract failed", "error"); + } + closeContextMenu(); + }, [contextMenu, nestNode, closeContextMenu]); + + if (!contextMenu) return null; + + const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed"; + const isOnline = contextMenu.nodeData.status === "online"; + const isPaused = contextMenu.nodeData.status === "paused"; + const isChild = !!contextMenu.nodeData.parentId; + + const items: MenuItem[] = [ + { label: "Details", icon: "i", action: handleViewDetails }, + { label: "Chat", icon: "💬", action: handleOpenChat, disabled: !isOnline }, + { label: "Terminal", icon: ">_", action: handleOpenTerminal, disabled: !isOnline }, + { label: "", icon: "", action: () => {}, divider: true }, + { label: "Export Bundle", icon: "📦", action: handleExportBundle }, + { label: "Duplicate", icon: "⧉", action: handleDuplicate }, + ...(isChild + ? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }] + : []), + ...(hasChildren + ? [{ label: "Collapse Team", icon: "◁", action: handleCollapse }] + : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), + { label: "", icon: "", action: () => {}, divider: true }, + ...(isPaused + ? [{ label: "Resume", icon: "▶", action: handleResume }] + : [{ label: "Pause", icon: "⏸", action: handlePause, disabled: !isOnline }]), + { label: "Restart", icon: "↻", action: handleRestart, disabled: !(isOfflineOrFailed || isPaused) }, + { label: "Delete", icon: "✕", action: handleDelete, danger: true }, + ]; + + return ( +
+ {/* Header */} +
+
{contextMenu.nodeData.name}
+
+
+ {contextMenu.nodeData.status} +
+
+ + {items.map((item, i) => { + if (item.divider) { + return
; + } + return ( + + ); + })} + + {/* Delete confirmation dialog */} + { setDeleteConfirm(null); closeContextMenu(); }} + /> +
+ ); +} diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx new file mode 100644 index 00000000..91467cbe --- /dev/null +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { api } from "@/lib/api"; +import { useCanvasStore } from "@/store/canvas"; +import { type ActivityEntry } from "@/types/activity"; +import { useWorkspaceName } from "@/hooks/useWorkspaceName"; + +interface Props { + open: boolean; + workspaceId: string; + onClose: () => void; +} + +function extractMessageText(body: Record | null): string { + if (!body) return ""; + try { + // Simple task format from MCP server: {task: "..."} + if (body.task && typeof body.task === "string") return body.task; + + // Request: params.message.parts[].text + const params = body.params as Record | undefined; + const message = params?.message as Record | undefined; + const parts = (message?.parts || []) as Array>; + const text = parts + .map((p) => (p.text as string) || "") + .filter(Boolean) + .join("\n"); + if (text) return text; + + // Response: result.parts[].text or result.parts[].root.text + const result = body.result as Record | undefined; + const rParts = (result?.parts || []) as Array>; + const rText = rParts + .map((p) => { + if (p.text) return p.text as string; + const root = p.root as Record | undefined; + return (root?.text as string) || ""; + }) + .filter(Boolean) + .join("\n"); + if (rText) return rText; + + if (typeof body.result === "string") return body.result; + } catch { /* ignore */ } + return ""; +} + +export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const nodes = useCanvasStore((s) => s.nodes); + const resolveName = useWorkspaceName(); + + // Fetch activities from all workspaces (including hidden children) and merge + useEffect(() => { + if (!open) return; + setLoading(true); + + const wsIds = nodes.map((n) => n.id); + + Promise.all( + wsIds.map((id) => + api + .get(`/workspaces/${id}/activity?limit=200`) + .catch(() => [] as ActivityEntry[]) + ) + ).then((results) => { + // Merge, deduplicate by ID, sort chronologically (oldest first) + const seen = new Set(); + const all: ActivityEntry[] = []; + for (const batch of results) { + for (const entry of batch) { + if (!seen.has(entry.id)) { + seen.add(entry.id); + all.push(entry); + } + } + } + all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + setEntries(all); + setLoading(false); + }); + }, [open, nodes]); + + if (!open) return null; + + const isA2A = (e: ActivityEntry) => + e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ Conversation Trace +

+

+ {entries.length} events across all workspaces +

+
+ +
+ + {/* Timeline */} +
+ {loading && ( +
+ Loading trace from all workspaces... +
+ )} + + {!loading && entries.length === 0 && ( +
+ No activity found +
+ )} + +
+ {entries.map((entry) => { + const time = new Date(entry.created_at).toLocaleTimeString(); + const wsName = resolveName(entry.workspace_id); + const sourceName = resolveName(entry.source_id); + const targetName = resolveName(entry.target_id); + const requestText = extractMessageText(entry.request_body); + const responseText = extractMessageText(entry.response_body); + const isError = entry.status === "error"; + const isSend = entry.activity_type === "a2a_send"; + const isReceive = entry.activity_type === "a2a_receive"; + + return ( +
+ {/* Event header */} +
+ {/* Timeline dot + line */} +
+
+
+
+ + {/* Content */} +
+
+ + {time} + + + {isSend + ? "SEND" + : isReceive + ? "RECEIVE" + : entry.activity_type.toUpperCase()} + + {entry.duration_ms != null && entry.duration_ms > 0 && ( + + {entry.duration_ms > 1000 + ? `${Math.round(entry.duration_ms / 1000)}s` + : `${entry.duration_ms}ms`} + + )} +
+ + {/* Flow */} + {isA2A(entry) && ( +
+ {isSend ? ( + + + {sourceName || wsName} + + + + {targetName} + + + ) : ( + + + {targetName || wsName} + + {sourceName && ( + <> + + {" "}← {" "} + + + {sourceName} + + + )} + + )} +
+ )} + + {/* Summary */} + {entry.summary && !isA2A(entry) && ( +
+ {wsName}:{" "} + {entry.summary} +
+ )} + + {/* Error */} + {isError && entry.error_detail && ( +
+ {entry.error_detail.slice(0, 200)} +
+ )} + + {/* Message content — show request and/or response */} + {requestText && ( +
+
+ {isSend ? "Task" : "Request"} +
+
+ {requestText.slice(0, 2000)} + {requestText.length > 2000 && ( + ...({requestText.length} chars) + )} +
+
+ )} + {responseText && ( +
+
Response
+
+ {responseText.slice(0, 2000)} + {responseText.length > 2000 && ( + ...({responseText.length} chars) + )} +
+
+ )} +
+
+
+ ); + })} +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx new file mode 100644 index 00000000..23496405 --- /dev/null +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/lib/api"; + +export function CreateWorkspaceButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + + {open && setOpen(false)} />} + + ); +} + +function CreateDialog({ onClose }: { onClose: () => void }) { + const [name, setName] = useState(""); + const [role, setRole] = useState(""); + const [tier, setTier] = useState(1); + const [template, setTemplate] = useState(""); + const [parentId, setParentId] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = async () => { + if (!name.trim()) { + setError("Name is required"); + return; + } + + setCreating(true); + setError(null); + + try { + await api.post("/workspaces", { + name: name.trim(), + role: role.trim() || undefined, + template: template.trim() || undefined, + tier, + parent_id: parentId.trim() || undefined, + canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, + }); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create workspace"); + } finally { + setCreating(false); + } + }; + + return ( +
+
+

Create Workspace

+

Add a new workspace node to the canvas

+ +
+ + + + +
+ +
+ {[ + { value: 1, label: "T1", desc: "Sandboxed" }, + { value: 2, label: "T2", desc: "Standard" }, + { value: 3, label: "T3", desc: "Full Access" }, + ].map((t) => ( + + ))} +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} + +function InputField({ + label, + value, + onChange, + placeholder, + required, + autoFocus, + mono, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + required?: boolean; + autoFocus?: boolean; + mono?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + autoFocus={autoFocus} + className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`} + /> +
+ ); +} diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx new file mode 100644 index 00000000..d8090e44 --- /dev/null +++ b/canvas/src/components/EmptyState.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { api } from "@/lib/api"; +import { useCanvasStore } from "@/store/canvas"; +import { OrgTemplatesSection } from "./TemplatePalette"; + +interface Template { + id: string; + name: string; + description: string; + tier: number; + model: string; + skills: string[]; + skill_count: number; +} + +const TIER_COLORS: Record = { + 1: "text-zinc-400 border-zinc-700/60", + 2: "text-sky-400 border-sky-500/30", + 3: "text-violet-400 border-violet-500/30", + 4: "text-amber-400 border-amber-500/30", +}; + +export function EmptyState() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [deploying, setDeploying] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + api + .get("/templates") + .then((t) => setTemplates(t)) + .catch(() => setTemplates([])) + .finally(() => setLoading(false)); + }, []); + + const deploy = async (template: Template) => { + setDeploying(template.id); + setError(null); + try { + const ws = await api.post<{ id: string }>("/workspaces", { + name: template.name, + template: template.id, + tier: template.tier, + canvas: { x: 200, y: 150 }, + }); + // Auto-select the new workspace and open chat + setTimeout(() => { + useCanvasStore.getState().selectNode(ws.id); + useCanvasStore.getState().setPanelTab("chat"); + }, 500); + } catch (e) { + setError(e instanceof Error ? e.message : "Deploy failed"); + } finally { + setDeploying(null); + } + }; + + const createBlank = async () => { + setDeploying("blank"); + setError(null); + try { + const ws = await api.post<{ id: string }>("/workspaces", { + name: "My First Agent", + tier: 2, + canvas: { x: 200, y: 150 }, + }); + setTimeout(() => { + useCanvasStore.getState().selectNode(ws.id); + useCanvasStore.getState().setPanelTab("chat"); + }, 500); + } catch (e) { + setError(e instanceof Error ? e.message : "Create failed"); + } finally { + setDeploying(null); + } + }; + + return ( +
+
+
+ + {/* Logo */} +
+ + + + + + +
+ +

+ Welcome to Molecule AI +

+

+ Deploy your first agent +

+

+ Pick a template to get started instantly, or create a blank workspace. +

+ + {/* Template grid */} + {loading ? ( +
Loading templates...
+ ) : templates.length > 0 ? ( +
+ {templates.slice(0, 6).map((t) => { + const tierColor = TIER_COLORS[t.tier] || TIER_COLORS[1]; + return ( + + ); + })} +
+ ) : null} + + {/* Create blank */} + + + {/* Org templates — instantiate a whole team in one click */} +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Tips */} +
+
+ Drag to nest workspaces into teams + | + Right-click for actions + | + Press ⌘K to search +
+
+
+
+ ); +} diff --git a/canvas/src/components/ErrorBoundary.tsx b/canvas/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..495da276 --- /dev/null +++ b/canvas/src/components/ErrorBoundary.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React from "react"; + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("ErrorBoundary caught an error:", error, errorInfo.componentStack); + } + + handleReload = () => { + window.location.reload(); + }; + + handleReport = () => { + const errorDetails = { + message: this.state.error?.message ?? "Unknown error", + stack: this.state.error?.stack ?? "N/A", + timestamp: new Date().toISOString(), + url: window.location.href, + }; + // Log the full report to console for collection by monitoring tools + console.error("Error Report:", JSON.stringify(errorDetails, null, 2)); + // Copy error info to clipboard for manual reporting + navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)).then( + () => window.alert("Error details copied to clipboard."), + () => window.alert("Error details logged to console.") + ); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
+ + + + + +
+

+ Something went wrong +

+

+ An unexpected error occurred while rendering the application. +

+

+ {this.state.error?.message ?? "Unknown error"} +

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx new file mode 100644 index 00000000..cac6f773 --- /dev/null +++ b/canvas/src/components/Legend.tsx @@ -0,0 +1,70 @@ +"use client"; + +export function Legend() { + return ( +
+
Legend
+ + {/* Status */} +
+
Status
+
+ + + + + + +
+
+ + {/* Tiers */} +
+
Tier
+
+ + + +
+
+ + {/* Communication */} +
+
Communication
+
+ + + + +
+
+
+ ); +} + +function StatusItem({ color, label }: { color: string; label: string }) { + return ( +
+
+ {label} +
+ ); +} + +function TierItem({ tier, label, color }: { tier: number; label: string; color: string }) { + return ( +
+ T{tier} + {label} +
+ ); +} + +function CommItem({ icon, color, label }: { icon: string; color: string; label: string }) { + return ( +
+ {icon} + {label} +
+ ); +} diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx new file mode 100644 index 00000000..ab66bece --- /dev/null +++ b/canvas/src/components/MissingKeysModal.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { getKeyLabel } from "@/lib/deploy-preflight"; + +interface Props { + open: boolean; + missingKeys: string[]; + runtime: string; + /** Called when user adds all keys and wants to proceed with deploy. */ + onKeysAdded: () => void; + /** Called when user cancels the deploy. */ + onCancel: () => void; + /** Called when user wants to open the Settings Panel (Config tab → Secrets). */ + onOpenSettings?: () => void; + /** Optional workspace ID — if provided, secrets are saved at workspace scope. */ + workspaceId?: string; +} + +interface KeyEntry { + key: string; + label: string; + value: string; + saved: boolean; + saving: boolean; + error: string | null; +} + +export function MissingKeysModal({ + open, + missingKeys, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: Props) { + const [entries, setEntries] = useState([]); + const [globalError, setGlobalError] = useState(null); + + // Initialize entries when modal opens or missingKeys change + useEffect(() => { + if (!open) return; + setEntries( + missingKeys.map((key) => ({ + key, + label: getKeyLabel(key), + value: "", + saved: false, + saving: false, + error: null, + })), + ); + setGlobalError(null); + }, [open, missingKeys]); + + // Keyboard handler + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onCancel]); + + const updateEntry = useCallback( + (index: number, updates: Partial) => { + setEntries((prev) => + prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)), + ); + }, + [], + ); + + const handleSaveKey = useCallback( + async (index: number) => { + const entry = entries[index]; + if (!entry.value.trim()) return; + + updateEntry(index, { saving: true, error: null }); + + try { + // Save to global scope by default (available to all workspaces) + if (workspaceId) { + await api.put(`/workspaces/${workspaceId}/secrets`, { + key: entry.key, + value: entry.value.trim(), + }); + } else { + await api.put("/settings/secrets", { + key: entry.key, + value: entry.value.trim(), + }); + } + updateEntry(index, { saved: true, saving: false }); + } catch (e) { + updateEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", + }); + } + }, + [entries, updateEntry, workspaceId], + ); + + const handleAddKeysAndDeploy = useCallback(() => { + const anySaving = entries.some((e) => e.saving); + if (anySaving) { + setGlobalError("Please wait for all keys to finish saving."); + return; + } + const allSaved = entries.every((e) => e.saved); + if (!allSaved) { + setGlobalError("Please save all required keys before deploying."); + return; + } + onKeysAdded(); + }, [entries, onKeysAdded]); + + if (!open) return null; + + const allSaved = entries.every((e) => e.saved); + const anySaving = entries.some((e) => e.saving); + const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+ + + + + +
+

+ Missing API Keys +

+
+

+ The {runtimeLabel} runtime + requires the following keys to be configured before deploying. +

+
+ + {/* Body — key list */} +
+ {entries.map((entry, index) => ( +
+
+
+
+ {entry.label} +
+
+ {entry.key} +
+
+ {entry.saved && ( + + + + + Saved + + )} +
+ + {!entry.saved && ( +
+ updateEntry(index, { value: e.target.value.trimStart() })} + placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"} + type="password" + autoFocus={index === 0} + onKeyDown={(e) => { + if (e.key === "Enter" && entry.value.trim()) { + handleSaveKey(index); + } + }} + className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors" + /> + +
+ )} + + {entry.error && ( +
{entry.error}
+ )} +
+ ))} + + {globalError && ( +
+ {globalError} +
+ )} +
+ + {/* Footer */} +
+
+ {onOpenSettings && ( + + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/canvas/src/components/OnboardingWizard.tsx b/canvas/src/components/OnboardingWizard.tsx new file mode 100644 index 00000000..9a7d1b13 --- /dev/null +++ b/canvas/src/components/OnboardingWizard.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useCanvasStore } from "@/store/canvas"; + +const STORAGE_KEY = "molecule-onboarding-complete"; + +type Step = "welcome" | "api-key" | "send-message" | "done"; + +const STEPS: { id: Step; title: string; description: string }[] = [ + { + id: "welcome", + title: "Welcome to Molecule AI", + description: + "Create your first workspace to deploy an agent. Pick a template from the center panel or create a blank workspace.", + }, + { + id: "api-key", + title: "Set your API key", + description: + "Your agent needs an API key to respond. Open the Config tab and add your Anthropic API key under Secrets.", + }, + { + id: "send-message", + title: "Send your first message", + description: + 'Switch to the Chat tab and say hello! Try: "What can you help me with?"', + }, + { + id: "done", + title: "You're all set!", + description: + "Your agent is ready. Explore skills, nest workspaces into teams, or deploy more agents from templates.", + }, +]; + +/** + * OnboardingWizard — guides first-time users through setup. + * Step 1: Welcome + create workspace (shown when canvas is empty) + * Step 2: API key setup (shown after first workspace created) + * Step 3: First message + * Step 4: Done + * + * Renders as a floating card in the bottom-left corner. + * Dismissible at any time. Progress tracked via localStorage. + */ +export function OnboardingWizard() { + const nodes = useCanvasStore((s) => s.nodes); + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const panelTab = useCanvasStore((s) => s.panelTab); + + const [dismissed, setDismissed] = useState(true); // default hidden until we check + const [step, setStep] = useState("welcome"); + + // Check localStorage on mount + useEffect(() => { + const done = localStorage.getItem(STORAGE_KEY); + if (done) { + setDismissed(true); + return; + } + // First-time user — show wizard + const currentNodes = useCanvasStore.getState().nodes; + setDismissed(false); + // Start at welcome if no workspaces, otherwise at api-key + setStep(currentNodes.length === 0 ? "welcome" : "api-key"); + }, []); + + // Auto-advance from "welcome" to "api-key" when first workspace appears + useEffect(() => { + if (step === "welcome" && nodes.length > 0) { + setStep("api-key"); + } + }, [step, nodes.length]); + + // Auto-advance steps based on user actions + useEffect(() => { + if (dismissed) return; + + if (step === "api-key" && panelTab === "config" && selectedNodeId) { + // User navigated to config — they'll set the key. Advance after a moment. + const timer = setTimeout(() => setStep("send-message"), 3000); + return () => clearTimeout(timer); + } + }, [step, panelTab, selectedNodeId, dismissed]); + + // Listen for agent messages to auto-advance to "done" + const agentMessages = useCanvasStore((s) => + selectedNodeId ? s.agentMessages[selectedNodeId] : undefined + ); + useEffect(() => { + if (step === "send-message" && agentMessages && agentMessages.length > 0) { + setStep("done"); + } + }, [step, agentMessages]); + + const dismiss = useCallback(() => { + setDismissed(true); + localStorage.setItem(STORAGE_KEY, "true"); + }, []); + + const handleAction = useCallback(() => { + if (step === "welcome") { + // No action needed — EmptyState handles workspace creation. + // If there are already nodes somehow, advance. + if (useCanvasStore.getState().nodes.length > 0) { + setStep("api-key"); + } + } else if (step === "api-key" && selectedNodeId) { + useCanvasStore.getState().setPanelTab("config"); + } else if (step === "send-message" && selectedNodeId) { + useCanvasStore.getState().setPanelTab("chat"); + } else if (step === "done") { + dismiss(); + } + }, [step, selectedNodeId, dismiss]); + + if (dismissed) return null; + + const currentStepIdx = STEPS.findIndex((s) => s.id === step); + const currentStep = STEPS[currentStepIdx]; + + return ( +
+ {/* Progress bar */} +
+
+
+ +
+ {/* Step indicator */} +
+ + Step {currentStepIdx + 1} of {STEPS.length} + + +
+ + {/* Content */} +

+ {currentStep.title} +

+

+ {currentStep.description} +

+ + {/* Action button */} +
+ + {step !== "done" && ( + + )} +
+
+
+ ); +} diff --git a/canvas/src/components/ProvisioningTimeout.tsx b/canvas/src/components/ProvisioningTimeout.tsx new file mode 100644 index 00000000..1945b125 --- /dev/null +++ b/canvas/src/components/ProvisioningTimeout.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { api } from "@/lib/api"; +import { showToast } from "./Toaster"; + +/** Default provisioning timeout in milliseconds (2 minutes). */ +export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000; + +interface TimeoutEntry { + workspaceId: string; + workspaceName: string; + startedAt: number; +} + +/** + * Monitors workspaces in "provisioning" status and shows a timeout banner + * with recovery actions (Retry, Cancel, View Logs) when provisioning takes + * too long. + * + * Rendered at the top of the canvas (inside Canvas component). Watches the + * Zustand store for nodes with status === "provisioning" and tracks elapsed + * time per node. + */ +export function ProvisioningTimeout({ + timeoutMs = DEFAULT_PROVISION_TIMEOUT_MS, +}: { + timeoutMs?: number; +}) { + const [timedOut, setTimedOut] = useState([]); + const [retrying, setRetrying] = useState>(new Set()); + const [cancelling, setCancelling] = useState>(new Set()); + const trackingRef = useRef>(new Map()); + + // Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render + // (filter+map creates new array reference on every store update) + const provisioningNodes = useCanvasStore((s) => { + const result = s.nodes + .filter((n) => n.data.status === "provisioning") + .map((n) => `${n.id}:${n.data.name}`); + return result.join(","); + }); + const parsedProvisioningNodes = useMemo( + () => + provisioningNodes + ? provisioningNodes.split(",").map((entry) => { + const [id, name] = entry.split(":"); + return { id, name }; + }) + : [], + [provisioningNodes], + ); + + useEffect(() => { + const tracking = trackingRef.current; + + // Start tracking new provisioning nodes + for (const node of parsedProvisioningNodes) { + if (!tracking.has(node.id)) { + tracking.set(node.id, Date.now()); + } + } + + // Remove tracking for nodes that are no longer provisioning + const activeIds = new Set(parsedProvisioningNodes.map((n) => n.id)); + for (const id of tracking.keys()) { + if (!activeIds.has(id)) { + tracking.delete(id); + } + } + + // Also remove from timedOut list if no longer provisioning + setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId))); + + // Interval to check for timeouts + const interval = setInterval(() => { + const now = Date.now(); + const newTimedOut: TimeoutEntry[] = []; + + for (const node of parsedProvisioningNodes) { + const startedAt = tracking.get(node.id); + if (startedAt && now - startedAt >= timeoutMs) { + newTimedOut.push({ + workspaceId: node.id, + workspaceName: node.name, + startedAt, + }); + } + } + + if (newTimedOut.length > 0) { + setTimedOut((prev) => { + const existingIds = new Set(prev.map((e) => e.workspaceId)); + const additions = newTimedOut.filter( + (e) => !existingIds.has(e.workspaceId), + ); + return additions.length > 0 ? [...prev, ...additions] : prev; + }); + } + }, 5_000); // check every 5s + + return () => clearInterval(interval); + }, [parsedProvisioningNodes, timeoutMs]); + + const RETRY_COOLDOWN_MS = 5_000; + const [retryCooldown, setRetryCooldown] = useState>(new Set()); + + const handleRetry = useCallback(async (workspaceId: string) => { + setRetrying((prev) => new Set(prev).add(workspaceId)); + try { + await api.post(`/workspaces/${workspaceId}/restart`); + // Remove from timed-out list — tracking will restart when provisioning event comes in + setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId)); + trackingRef.current.delete(workspaceId); + showToast("Retrying deployment...", "info"); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Retry failed", + "error", + ); + } finally { + setRetrying((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + // Start cooldown — disable retry button for 5s + setRetryCooldown((prev) => new Set(prev).add(workspaceId)); + setTimeout(() => { + setRetryCooldown((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + }, RETRY_COOLDOWN_MS); + } + }, []); + + const [confirmingCancel, setConfirmingCancel] = useState(null); + + const handleCancelRequest = useCallback((workspaceId: string) => { + setConfirmingCancel(workspaceId); + }, []); + + const handleCancelConfirm = useCallback(async () => { + if (!confirmingCancel) return; + const workspaceId = confirmingCancel; + setConfirmingCancel(null); + setCancelling((prev) => new Set(prev).add(workspaceId)); + try { + await api.del(`/workspaces/${workspaceId}`); + setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId)); + trackingRef.current.delete(workspaceId); + showToast("Deployment cancelled", "info"); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Cancel failed", + "error", + ); + } finally { + setCancelling((prev) => { + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + } + }, [confirmingCancel]); + + const handleViewLogs = useCallback((workspaceId: string) => { + // Open the terminal tab for this workspace so user can see logs + useCanvasStore.getState().selectNode(workspaceId); + useCanvasStore.getState().setPanelTab("terminal"); + }, []); + + if (timedOut.length === 0) return null; + + return ( +
+ {timedOut.map((entry) => { + const elapsed = Math.round((Date.now() - entry.startedAt) / 1000); + const isRetrying = retrying.has(entry.workspaceId); + const isCancelling = cancelling.has(entry.workspaceId); + + return ( +
+
+ {/* Warning icon */} +
+ + + + + +
+ +
+
+ Provisioning Timeout +
+
+ {entry.workspaceName}{" "} + has been provisioning for{" "} + {formatDuration(elapsed)}. + It may have encountered an issue. +
+ + {/* Action buttons */} +
+ + + +
+
+
+
+ ); + })} + + {/* Cancel confirmation dialog */} + {confirmingCancel && ( +
+
setConfirmingCancel(null)} /> +
+

+ Cancel deployment? +

+

+ This will permanently remove the workspace. This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ); +} + +/** Format seconds into a human-friendly string like "2m 30s" */ +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +} diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx new file mode 100644 index 00000000..c1e28a6b --- /dev/null +++ b/canvas/src/components/SearchDialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useCanvasStore } from "@/store/canvas"; + +export function SearchDialog() { + const open = useCanvasStore((s) => s.searchOpen); + const setOpen = useCanvasStore((s) => s.setSearchOpen); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const nodes = useCanvasStore((s) => s.nodes); + const selectNode = useCanvasStore((s) => s.selectNode); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); + + // Cmd+K to open + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen(true); + setQuery(""); + } + if (e.key === "Escape" && open) { + setOpen(false); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, setOpen]); + + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + const filtered = nodes.filter((n) => { + if (!query) return true; + const q = query.toLowerCase(); + return ( + n.data.name.toLowerCase().includes(q) || + (n.data.role || "").toLowerCase().includes(q) || + n.data.status.toLowerCase().includes(q) + ); + }); + + const handleSelect = useCallback( + (nodeId: string) => { + selectNode(nodeId); + setPanelTab("details"); + setOpen(false); + }, + [selectNode, setPanelTab] + ); + + if (!open) return null; + + return ( +
setOpen(false)}> +
e.stopPropagation()} + > + {/* Search input */} +
+ + + + + setQuery(e.target.value)} + placeholder="Search workspaces..." + className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none" + /> + ESC +
+ + {/* Results */} +
+ {filtered.length === 0 ? ( +
+ {query ? "No workspaces match" : "No workspaces yet"} +
+ ) : ( + filtered.map((node) => ( + + )) + )} +
+ + {/* Footer */} +
+ {filtered.length} workspace{filtered.length !== 1 ? "s" : ""} +
+ ↵ select +
+
+
+
+ ); +} diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx new file mode 100644 index 00000000..0a18358b --- /dev/null +++ b/canvas/src/components/SidePanel.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { useCanvasStore, type PanelTab } from "@/store/canvas"; +import { showToast } from "@/components/Toaster"; +import { StatusDot } from "./StatusDot"; +import { Tooltip } from "./Tooltip"; +import { DetailsTab } from "./tabs/DetailsTab"; +import { SkillsTab } from "./tabs/SkillsTab"; +import { ChatTab } from "./tabs/ChatTab"; +import { ConfigTab } from "./tabs/ConfigTab"; +import { TerminalTab } from "./tabs/TerminalTab"; +import { FilesTab } from "./tabs/FilesTab"; +import { MemoryTab } from "./tabs/MemoryTab"; +import { TracesTab } from "./tabs/TracesTab"; +import { EventsTab } from "./tabs/EventsTab"; +import { ActivityTab } from "./tabs/ActivityTab"; +import { ScheduleTab } from "./tabs/ScheduleTab"; +import { ChannelsTab } from "./tabs/ChannelsTab"; +import { summarizeWorkspaceCapabilities } from "@/store/canvas"; + +const TABS: { id: PanelTab; label: string; icon: string }[] = [ + { id: "chat", label: "Chat", icon: "◈" }, + { id: "activity", label: "Activity", icon: "⊙" }, + { id: "details", label: "Details", icon: "◉" }, + { id: "skills", label: "Skills", icon: "✦" }, + { id: "terminal", label: "Terminal", icon: "▸" }, + { id: "config", label: "Config", icon: "⚙" }, + { id: "schedule", label: "Schedule", icon: "⏲" }, + { id: "channels", label: "Channels", icon: "⇌" }, + { id: "files", label: "Files", icon: "⊞" }, + { id: "memory", label: "Memory", icon: "◇" }, + { id: "traces", label: "Traces", icon: "◎" }, + { id: "events", label: "Events", icon: "◊" }, +]; + +export function SidePanel() { + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const panelTab = useCanvasStore((s) => s.panelTab); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); + const selectNode = useCanvasStore((s) => s.selectNode); + const node = useCanvasStore((s) => + s.nodes.find((n) => n.id === s.selectedNodeId) + ); + + // Resizable panel width + const [width, setWidth] = useState(480); + const dragging = useRef(false); + const startX = useRef(0); + const startWidth = useRef(480); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + startX.current = e.clientX; + startWidth.current = width; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [width]); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const delta = startX.current - e.clientX; + const newWidth = Math.min(Math.max(startWidth.current + delta, 320), window.innerWidth * 0.8); + setWidth(newWidth); + }; + const onMouseUp = () => { + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + + if (!selectedNodeId || !node) return null; + + const isOnline = node.data.status === "online"; + const capability = summarizeWorkspaceCapabilities(node.data); + + return ( +
+ {/* Resize handle */} +
+ {/* Header */} +
+
+
+ +
+
+

+ {node.data.name} +

+
+ {node.data.role && ( + + {node.data.role} + + )} + + T{node.data.tier} + +
+
+
+ +
+ + {/* Capability summary */} +
+
+ + + 0 ? `${capability.skillCount}` : "none"} /> + +
+
+ + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Needs Restart Banner */} + {node.data.needsRestart && !node.data.currentTask && selectedNodeId && ( +
+ Config changed — restart to apply + +
+ )} + + {/* Current Task Banner */} + {node.data.currentTask && ( + +
+
+ + {node.data.currentTask} + +
+ + )} + + {/* Tab Content */} +
+ {panelTab === "details" && } + {panelTab === "skills" && } + {panelTab === "activity" && } + {panelTab === "chat" && } + {panelTab === "terminal" && } + {panelTab === "config" && } + {panelTab === "schedule" && } + {panelTab === "channels" && } + {panelTab === "files" && } + {panelTab === "memory" && } + {panelTab === "traces" && } + {panelTab === "events" && } +
+ + {/* Footer — workspace ID */} +
+ + {selectedNodeId} + +
+
+ ); +} + +function MetaPill({ label, value, tone = "zinc" }: { label: string; value: string; tone?: "zinc" | "emerald" | "amber" }) { + const toneClasses = { + zinc: "border-zinc-700/50 bg-zinc-900/70 text-zinc-400", + emerald: "border-emerald-500/20 bg-emerald-950/20 text-emerald-300", + amber: "border-amber-500/20 bg-amber-950/20 text-amber-300", + }[tone]; + + return ( + + {label} + {value} + + ); +} diff --git a/canvas/src/components/StatusDot.tsx b/canvas/src/components/StatusDot.tsx new file mode 100644 index 00000000..01cdaab0 --- /dev/null +++ b/canvas/src/components/StatusDot.tsx @@ -0,0 +1,26 @@ +"use client"; + +export const STATUS_COLORS: Record = { + online: "bg-emerald-400", + offline: "bg-zinc-500", + paused: "bg-indigo-400", + degraded: "bg-amber-400", + failed: "bg-red-400", + provisioning: "bg-sky-400 animate-pulse", +}; + +export function StatusDot({ + status, + size = "sm", +}: { + status: string; + size?: "sm" | "md"; +}) { + const sizeClass = size === "md" ? "w-2.5 h-2.5" : "w-2 h-2"; + const glowClass = status === "online" ? "shadow-sm shadow-emerald-400/50" : ""; + return ( +
+ ); +} diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx new file mode 100644 index 00000000..ba821834 --- /dev/null +++ b/canvas/src/components/TemplatePalette.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { api } from "@/lib/api"; +import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight"; +import { MissingKeysModal } from "./MissingKeysModal"; + +interface Template { + id: string; + name: string; + description: string; + tier: number; + model: string; + skills: string[]; + skill_count: number; +} + +export interface OrgTemplate { + dir: string; + name: string; + description: string; + workspaces: number; +} + +/** Fetch the list of org templates from the platform. Returns [] on error + * so the UI shows the empty state instead of crashing. */ +export async function fetchOrgTemplates(): Promise { + try { + return await api.get("/org/templates"); + } catch { + return []; + } +} + +/** Import an org template by directory name. Throws on platform error so the + * caller can surface the message in its error state. */ +export async function importOrgTemplate(dir: string): Promise { + await api.post("/org/import", { dir }); +} + +/** + * Section listing org templates (multi-workspace hierarchies). Click "Import" + * to instantiate the entire tree via `POST /org/import { dir }`. PLAN.md §20.3. + * + * Exported separately so the org import flow has a focused unit-test surface + * without re-rendering the full palette. + */ +export function OrgTemplatesSection() { + const [orgs, setOrgs] = useState([]); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(null); + const [error, setError] = useState(null); + + const loadOrgs = useCallback(async () => { + setLoading(true); + setOrgs(await fetchOrgTemplates()); + setLoading(false); + }, []); + + useEffect(() => { + loadOrgs(); + }, [loadOrgs]); + + const handleImport = async (org: OrgTemplate) => { + setImporting(org.dir); + setError(null); + try { + await importOrgTemplate(org.dir); + } catch (e) { + setError(e instanceof Error ? e.message : "Import failed"); + } finally { + setImporting(null); + } + }; + + return ( +
+
+

+ Org Templates +

+ +
+ + {loading &&
Loading…
} + + {!loading && orgs.length === 0 && ( +
+ No org templates in org-templates/ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {orgs.map((o) => { + const isImporting = importing === o.dir; + return ( +
+
+ + {o.name || o.dir} + + + {o.workspaces}w + +
+ {o.description && ( +

+ {o.description} +

+ )} + +
+ ); + })} +
+ ); +} + +const TIER_LABELS: Record = { + 1: { label: "T1", color: "text-zinc-400 bg-zinc-800/60" }, + 2: { label: "T2", color: "text-sky-400 bg-sky-950/40" }, + 3: { label: "T3", color: "text-violet-400 bg-violet-950/40" }, + 4: { label: "T4", color: "text-amber-400 bg-amber-950/40" }, +}; + +function ImportAgentButton({ onImported }: { onImported: () => void }) { + const [importing, setImporting] = useState(false); + const fileInputRef = useRef(null); + + const handleFiles = async (fileList: FileList) => { + setImporting(true); + try { + const files: Record = {}; + let agentName = ""; + + for (const file of Array.from(fileList)) { + // webkitRelativePath gives us "folder/file.md" + const path = file.webkitRelativePath || file.name; + // Strip the top-level folder name + const parts = path.split("/"); + if (!agentName && parts.length > 1) { + agentName = parts[0]; + } + const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0]; + + // Only import text files + if (file.size > 1_000_000) continue; // skip files > 1MB + try { + const content = await file.text(); + files[relPath] = content; + } catch { + // Skip binary files + } + } + + if (Object.keys(files).length === 0) { + alert("No files found in the selected folder"); + return; + } + + const name = agentName || "Imported Agent"; + await api.post("/templates/import", { name, files }); + onImported(); + } catch (e) { + alert(e instanceof Error ? e.message : "Import failed"); + } finally { + setImporting(false); + } + }; + + return ( +
+ e.target.files && handleFiles(e.target.files)} + /> + +
+ ); +} + +export function TemplatePalette() { + const [open, setOpen] = useState(false); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(null); + const [error, setError] = useState(null); + + // Missing keys modal state + const [missingKeysInfo, setMissingKeysInfo] = useState<{ + template: Template; + preflight: PreflightResult; + } | null>(null); + + const loadTemplates = useCallback(async () => { + setLoading(true); + try { + const data = await api.get("/templates"); + setTemplates(data); + } catch { + setTemplates([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) loadTemplates(); + }, [open, loadTemplates]); + + /** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */ + const resolveRuntime = (templateId: string): string => { + const runtimeMap: Record = { + langgraph: "langgraph", + "claude-code-default": "claude-code", + openclaw: "openclaw", + deepagents: "deepagents", + crewai: "crewai", + autogen: "autogen", + }; + return runtimeMap[templateId] ?? templateId.replace(/-default$/, ""); + }; + + /** Actually execute the deploy API call */ + const executeDeploy = useCallback(async (template: Template) => { + setCreating(template.id); + setError(null); + try { + await api.post("/workspaces", { + name: template.name, + template: template.id, + tier: template.tier, + canvas: { + x: Math.random() * 400 + 100, + y: Math.random() * 300 + 100, + }, + }); + setCreating(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to deploy"); + setCreating(null); + } + }, []); + + /** Pre-deploy check: validate secrets before deploying */ + const handleDeploy = async (template: Template) => { + setCreating(template.id); + setError(null); + + const runtime = resolveRuntime(template.id); + const preflight = await checkDeploySecrets(runtime); + + if (!preflight.ok) { + // Missing keys — show the modal instead of deploying + setMissingKeysInfo({ template, preflight }); + setCreating(null); + return; + } + + // All keys present — deploy directly + await executeDeploy(template); + }; + + return ( + <> + {/* Toggle button */} + + + {/* Missing Keys Modal */} + { + if (missingKeysInfo) { + const template = missingKeysInfo.template; + setMissingKeysInfo(null); + executeDeploy(template); + } + }} + onCancel={() => setMissingKeysInfo(null)} + /> + + {/* Sidebar */} + {open && ( +
+
+

Templates

+

Click to deploy a workspace

+
+ +
+ {loading && ( +
Loading...
+ )} + + {!loading && templates.length === 0 && ( +
+ No templates found in
workspace-configs-templates/ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {templates.map((t) => { + const tierCfg = TIER_LABELS[t.tier] || TIER_LABELS[1]; + const isDeploying = creating === t.id; + + return ( + + ); + })} +
+ +
+ + + +
+
+ )} + + ); +} diff --git a/canvas/src/components/Toaster.tsx b/canvas/src/components/Toaster.tsx new file mode 100644 index 00000000..37dbe22f --- /dev/null +++ b/canvas/src/components/Toaster.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Toast { + id: string; + message: string; + type: "success" | "error" | "info"; +} + +let addToastFn: ((message: string, type?: Toast["type"]) => void) | null = null; + +/** Call from anywhere to show a toast */ +export function showToast(message: string, type: Toast["type"] = "info") { + addToastFn?.(message, type); +} + +export function Toaster() { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + addToastFn = (message, type = "info") => { + const id = Math.random().toString(36).slice(2); + setToasts((prev) => [...prev.slice(-4), { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); + }; + return () => { addToastFn = null; }; + }, []); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+ ); +} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx new file mode 100644 index 00000000..ce766c32 --- /dev/null +++ b/canvas/src/components/Toolbar.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMemo, useState, useCallback, useEffect, useRef } from "react"; +import { api } from "@/lib/api"; +import { useCanvasStore } from "@/store/canvas"; +import { SettingsButton } from "@/components/settings/SettingsButton"; +import { settingsGearRef } from "@/components/settings/SettingsPanel"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { showToast } from "@/components/Toaster"; + +export function Toolbar() { + const nodes = useCanvasStore((s) => s.nodes); + + const [stopping, setStopping] = useState(false); + const [restartingAll, setRestartingAll] = useState(false); + const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const helpRef = useRef(null); + + const counts = useMemo(() => { + const c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 }; + for (const n of nodes) { + if (n.data.parentId) c.children++; else c.roots++; + const s = n.data.status; + if (s === "online") c.online++; + else if (s === "offline") c.offline++; + else if (s === "failed") c.failed++; + else if (s === "provisioning") c.provisioning++; + if ((n.data.activeTasks as number) > 0) c.activeTasks++; + } + return c; + }, [nodes]); + + const stopAll = useCallback(async () => { + setStopping(true); + const active = nodes.filter((n) => (n.data.activeTasks as number) > 0); + await Promise.all( + active.map((n) => + api.post(`/workspaces/${n.id}/restart`).catch(() => {}) + ) + ); + setStopping(false); + }, [nodes]); + + // Workspaces flagged as needing restart (config edited, global secret changed, etc.) + const needsRestartNodes = useMemo( + () => nodes.filter((n) => n.data.needsRestart), + [nodes] + ); + + const restartAll = useCallback(async () => { + setRestartConfirmOpen(false); + setRestartingAll(true); + const targets = needsRestartNodes; + const results = await Promise.allSettled( + targets.map((n) => api.post(`/workspaces/${n.id}/restart`)) + ); + const failed = results.filter((r) => r.status === "rejected").length; + setRestartingAll(false); + // Clear needsRestart on successfully-restarted workspaces + const store = useCanvasStore.getState(); + targets.forEach((n, i) => { + if (results[i].status === "fulfilled") { + store.updateNodeData(n.id, { needsRestart: false }); + } + }); + if (failed === 0) { + showToast(`Restarted ${targets.length} workspace${targets.length === 1 ? "" : "s"}`, "success"); + } else if (failed === targets.length) { + showToast(`Failed to restart any workspaces`, "error"); + } else { + showToast(`Restarted ${targets.length - failed} of ${targets.length} (${failed} failed)`, "error"); + } + }, [needsRestartNodes]); + + useEffect(() => { + const onPointerDown = (event: MouseEvent) => { + if (helpRef.current && !helpRef.current.contains(event.target as Node)) { + setHelpOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setHelpOpen(false); + } + }; + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, []); + + return ( +
+ {/* Logo / Title */} +
+ Molecule AI + Molecule AI +
+ + {/* Status counts */} +
+ + {counts.offline > 0 && ( + + )} + {counts.provisioning > 0 && ( + + )} + {counts.failed > 0 && ( + + )} +
+ + {/* Total */} +
+ + {counts.roots} workspace{counts.roots !== 1 ? "s" : ""} + {counts.children > 0 && + {counts.children} sub} + +
+ + {/* Stop All — visible when agents have active tasks */} + {counts.activeTasks > 0 && ( + + )} + + {/* Restart All — only shows when workspaces are flagged as needsRestart */} + {needsRestartNodes.length > 0 && ( + + )} + + {/* Search shortcut */} + + + {/* Quick help */} +
+ + + {helpOpen && ( +
+
+ Quick start + +
+
+ + + + + +
+
+ )} +
+ + {/* Settings gear icon */} + + + setRestartConfirmOpen(false)} + /> +
+ ); +} + +function StatusPill({ color, count, label }: { color: string; count: number; label: string }) { + return ( +
+
+ {count} +
+ ); +} + +function HelpRow({ shortcut, text }: { shortcut: string; text: string }) { + return ( +
+ + {shortcut} + +

{text}

+
+ ); +} diff --git a/canvas/src/components/Tooltip.tsx b/canvas/src/components/Tooltip.tsx new file mode 100644 index 00000000..dbda50d2 --- /dev/null +++ b/canvas/src/components/Tooltip.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + text: string; + children: ReactNode; +} + +export function Tooltip({ text, children }: Props) { + const [show, setShow] = useState(false); + const [pos, setPos] = useState({ x: 0, y: 0 }); + const timerRef = useRef>(undefined); + const triggerRef = useRef(null); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const enter = useCallback(() => { + timerRef.current = setTimeout(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setPos({ x: rect.left, y: rect.top }); + } + setShow(true); + }, 400); + }, []); + + const leave = useCallback(() => { + clearTimeout(timerRef.current); + setShow(false); + }, []); + + return ( +
+ {children} + {show && text && createPortal( +
+
+ {text} +
+
, + document.body + )} +
+ ); +} diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx new file mode 100644 index 00000000..3b884d12 --- /dev/null +++ b/canvas/src/components/WorkspaceNode.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { showToast } from "@/components/Toaster"; +import { Tooltip } from "@/components/Tooltip"; +import { useShallow } from "zustand/react/shallow"; + +/** Stable selector: returns children, grandchild flag, and descendant count for a node */ +function useHierarchyInfo(parentId: string) { + const childIds = useCanvasStore( + useCallback((s) => s.nodes.filter((n) => n.data.parentId === parentId).map((n) => n.id).join(","), [parentId]) + ); + const children = useCanvasStore( + useShallow((s) => s.nodes.filter((n) => n.data.parentId === parentId)) + ); + const hasGrandchildren = useCanvasStore( + useCallback((s) => { + const ids = childIds.split(",").filter(Boolean); + return ids.length > 0 && ids.some((cid) => s.nodes.some((n) => n.data.parentId === cid)); + }, [childIds]) + ); + const descendantCount = useCanvasStore( + useCallback((s) => countDescendants(parentId, s.nodes), [parentId]) + ); + return { children, hasGrandchildren, descendantCount }; +} + +const STATUS_CONFIG: Record = { + online: { dot: "bg-emerald-400", glow: "shadow-emerald-400/50", label: "Online", bar: "from-emerald-500/20 to-transparent" }, + offline: { dot: "bg-zinc-500", glow: "", label: "Offline", bar: "from-zinc-600/10 to-transparent" }, + paused: { dot: "bg-indigo-400", glow: "", label: "Paused", bar: "from-indigo-500/10 to-transparent" }, + degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" }, + failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" }, + provisioning: { dot: "bg-sky-400 animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" }, +}; + +/** Eject/extract arrow icon — visually distinct from delete ✕ */ +function EjectIcon() { + return ( + + + + + ); +} + +const TIER_CONFIG: Record = { + 1: { label: "T1", color: "text-zinc-500 bg-zinc-800/80" }, + 2: { label: "T2", color: "text-sky-400 bg-sky-950/50" }, + 3: { label: "T3", color: "text-violet-400 bg-violet-950/50" }, + 4: { label: "T4", color: "text-amber-400 bg-amber-950/50" }, +}; + +export function WorkspaceNode({ id, data }: NodeProps>) { + const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; + const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const selectNode = useCanvasStore((s) => s.selectNode); + const openContextMenu = useCanvasStore((s) => s.openContextMenu); + const nestNode = useCanvasStore((s) => s.nestNode); + const isDragTarget = useCanvasStore((s) => s.dragOverNodeId === id); + const isSelected = selectedNodeId === id; + const isOnline = data.status === "online"; + + // Get children + hierarchy info (single stable selector avoids redundant re-renders) + const { children, hasGrandchildren, descendantCount } = useHierarchyInfo(id); + const hasChildren = children.length > 0; + + const skills = getSkillNames(data.agentCard); + + const handleExtract = useCallback( + (childId: string) => nestNode(childId, null), + [nestNode] + ); + + return ( +
{ + e.stopPropagation(); + selectNode(isSelected ? null : id); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + if (hasChildren) { + window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } })); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ x: e.clientX, y: e.clientY, nodeId: id, nodeData: data }); + }} + className={` + group relative rounded-xl + ${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"} + cursor-pointer overflow-hidden + transition-all duration-200 ease-out + ${isDragTarget + ? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]" + : isSelected + ? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10" + : "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40" + } + backdrop-blur-sm + `} + > + {/* Status gradient bar at top */} +
+ + + +
+ {/* Header row */} +
+
+
+ + {data.name} + +
+
+ {hasChildren && ( + + {descendantCount} sub + + )} + + {tierCfg.label} + +
+
+ + {/* Runtime badge — prefers workspace.runtime (DB column) over + agent_card.runtime (agent-reported). Phase 30 remote agents + (runtime='external') get a distinct purple "REMOTE" pill. + We treat empty-string DB values as "missing" so an unbackfilled + row falls through to the agent-card value rather than rendering + a blank pill. */} + {(() => { + const dbRuntime = typeof data.runtime === "string" && data.runtime !== "" + ? data.runtime : null; + const cardRuntime = data.agentCard && typeof (data.agentCard as Record).runtime === "string" + ? (data.agentCard as Record).runtime + : null; + const runtime = dbRuntime ?? cardRuntime; + if (!runtime) return null; + return ( +
+ {runtime === "external" ? ( + + ★ REMOTE + + ) : ( + + {runtime} + + )} +
+ ); + })()} + + {/* Role */} + {data.role && ( +
{data.role}
+ )} + + {/* Skills */} + {skills.length > 0 && ( +
+ {skills.slice(0, 4).map((skill) => ( + + {skill} + + ))} + {skills.length > 4 && ( + + +{skills.length - 4} + + )} +
+ )} + + {/* Embedded children — rendered INSIDE the parent node */} + {hasChildren && ( + + )} + + {/* Current task */} + {data.currentTask && ( + +
+
+ {data.currentTask} +
+ + )} + + {/* Needs restart banner */} + {data.needsRestart && !data.currentTask && ( + + )} + + {/* Bottom row: status / active tasks */} +
+ {data.status !== "online" ? ( +
+ {statusCfg.label} +
+ ) :
} + + {data.activeTasks > 0 && ( +
+
+ + {data.activeTasks} task{data.activeTasks > 1 ? "s" : ""} + +
+ )} +
+ + {/* Degraded error preview */} + {data.status === "degraded" && data.lastSampleError && ( +
+ {data.lastSampleError} +
+ )} +
+ + +
+ ); +} + +const MAX_NESTING_DEPTH = 3; + +/** Count all descendants (children + grandchildren + ...) */ +function countDescendants(nodeId: string, allNodes: Node[], visited = new Set()): number { + if (visited.has(nodeId)) return 0; + visited.add(nodeId); + const directChildren = allNodes.filter((n) => n.data.parentId === nodeId); + let count = directChildren.length; + for (const child of directChildren) { + count += countDescendants(child.id, allNodes, visited); + } + return count; +} + +/** Subscribes to allNodes only when children exist — isolates re-renders from parent */ +function EmbeddedTeam({ members, depth, onSelect, onExtract }: { + members: Node[]; + depth: number; + onSelect: (id: string) => void; + onExtract: (id: string) => void; +}) { + const allNodes = useCanvasStore((s) => s.nodes); + // Use grid layout at depth 0 when there are multiple members (departments side-by-side) + const useGrid = depth === 0 && members.length >= 2; + return ( +
+
Team Members
+
+ {members.map((child) => ( + + ))} +
+
+ ); +} + +/** Recursive mini-card — mirrors parent card layout at smaller scale */ +function TeamMemberChip({ + node, + allNodes, + depth, + onSelect, + onExtract, +}: { + node: Node; + allNodes: Node[]; + depth: number; + onSelect: (id: string) => void; + onExtract: (id: string) => void; +}) { + const { data } = node; + const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; + const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; + const isOnline = data.status === "online"; + const skills = getSkillNames(data.agentCard); + + const subChildren = useMemo( + () => allNodes.filter((n) => n.data.parentId === node.id), + [allNodes, node.id] + ); + const hasSubChildren = subChildren.length > 0; + const descendantCount = useMemo( + () => hasSubChildren ? countDescendants(node.id, allNodes) : 0, + [allNodes, node.id, hasSubChildren] + ); + + return ( +
{ + e.stopPropagation(); + onSelect(node.id); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data }); + }} + > + {/* Status gradient bar */} +
+ +
+ {/* Header: name + badges + extract */} +
+
+
+ + {data.name} + +
+
+ {hasSubChildren && ( + + {descendantCount} + + )} + + {tierCfg.label} + + +
+
+ + {/* Role */} + {data.role && ( +
{data.role}
+ )} + + {/* Skills */} + {skills.length > 0 && ( +
+ {skills.slice(0, 3).map((skill) => ( + + {skill} + + ))} + {skills.length > 3 && ( + +{skills.length - 3} + )} +
+ )} + + {/* Status + active tasks row */} +
+ {data.status !== "online" ? ( + + {statusCfg.label} + + ) :
} + {data.activeTasks > 0 && ( +
+
+ + {data.activeTasks} + +
+ )} +
+ + {/* Current task banner for sub-agents */} + {data.currentTask && ( + +
+
+ {data.currentTask} +
+ + )} + + {/* Recursive sub-children rendered inside this card */} + {hasSubChildren && depth < MAX_NESTING_DEPTH && ( +
+
Team
+
= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}> + {subChildren.map((sub) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function getSkillNames(agentCard: Record | null): string[] { + if (!agentCard) return []; + const skills = agentCard.skills; + if (!Array.isArray(skills)) return []; + return skills.map((s: Record) => + String(s.name || s.id || "") + ).filter(Boolean); +} diff --git a/canvas/src/components/__tests__/ErrorBoundary.test.tsx b/canvas/src/components/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 00000000..32e51941 --- /dev/null +++ b/canvas/src/components/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// We test ErrorBoundary in a pure-unit style by instantiating the class +// directly, avoiding the need for a full React DOM renderer (which the +// project's vitest environment = "node" does not provide). +// --------------------------------------------------------------------------- + +// Mock fetch globally so transitive imports of api.ts don't blow up +globalThis.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response) +); + +import React from "react"; +import { ErrorBoundary } from "../ErrorBoundary"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createInstance(props: { children: React.ReactNode } = { children: null }) { + const instance = new ErrorBoundary(props); + return instance; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ErrorBoundary", () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + // ---- static getDerivedStateFromError ----------------------------------- + + it("getDerivedStateFromError returns error state", () => { + const err = new Error("boom"); + const state = ErrorBoundary.getDerivedStateFromError(err); + expect(state).toEqual({ hasError: true, error: err }); + }); + + // ---- componentDidCatch ------------------------------------------------ + + it("componentDidCatch logs to console.error", () => { + const instance = createInstance(); + const err = new Error("render failure"); + const info: React.ErrorInfo = { componentStack: "\n " } as React.ErrorInfo; + + instance.componentDidCatch(err, info); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "ErrorBoundary caught an error:", + err, + "\n " + ); + }); + + // ---- initial state (no error) ----------------------------------------- + + it("initial state has no error", () => { + const instance = createInstance(); + expect(instance.state.hasError).toBe(false); + expect(instance.state.error).toBeNull(); + }); + + // ---- render with no error returns children ---------------------------- + + it("render returns children when there is no error", () => { + const child = React.createElement("div", null, "Hello"); + const instance = createInstance({ children: child }); + // state should be no-error, so render() returns children + const result = instance.render(); + expect(result).toBe(child); + }); + + // ---- render with error returns fallback UI ---------------------------- + + it("render returns fallback UI when hasError is true", () => { + const instance = createInstance({ children: React.createElement("div") }); + // Simulate error state + instance.state = { hasError: true, error: new Error("test crash") }; + + const result = instance.render(); + + // result should be a React element (the fallback), not the children + expect(result).not.toBeNull(); + expect(typeof result).toBe("object"); + // The fallback is a div, not the original children + const element = result as React.ReactElement<{ className?: string }>; + expect(element.props?.className).toContain("fixed"); + expect(element.props?.className).toContain("inset-0"); + }); + + // ---- fallback UI contains error message -------------------------------- + + it("fallback UI includes the error message text", () => { + const instance = createInstance({ children: React.createElement("div") }); + instance.state = { hasError: true, error: new Error("kaboom!") }; + + const result = instance.render() as React.ReactElement; + // Deep-search the rendered tree for the error message + const json = JSON.stringify(result); + expect(json).toContain("kaboom!"); + expect(json).toContain("Something went wrong"); + expect(json).toContain("Reload"); + expect(json).toContain("Report"); + }); + + // ---- fallback UI renders safely with null error ----------------------- + + it("fallback UI handles null error gracefully", () => { + const instance = createInstance({ children: React.createElement("div") }); + instance.state = { hasError: true, error: null }; + + const result = instance.render() as React.ReactElement; + const json = JSON.stringify(result); + expect(json).toContain("Unknown error"); + expect(json).toContain("Something went wrong"); + }); +}); diff --git a/canvas/src/components/__tests__/MissingKeysModal.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.test.tsx new file mode 100644 index 00000000..bf5e0953 --- /dev/null +++ b/canvas/src/components/__tests__/MissingKeysModal.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock fetch globally +global.fetch = vi.fn(); + +// Test the deploy-preflight integration and modal-related logic +// (Component rendering with hooks requires jsdom; we test logic here) +import { + getRequiredKeys, + findMissingKeys, + getKeyLabel, + checkDeploySecrets, + RUNTIME_REQUIRED_KEYS, +} from "../../lib/deploy-preflight"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("MissingKeysModal integration logic", () => { + it("MissingKeysModal module can be imported", async () => { + // Verify the module exports the component (even though we can't render it in node env) + const mod = await import("../MissingKeysModal"); + expect(mod.MissingKeysModal).toBeDefined(); + expect(typeof mod.MissingKeysModal).toBe("function"); + }); + + it("identifies missing keys for langgraph runtime", () => { + const configured = new Set(); + const missing = findMissingKeys("langgraph", configured); + expect(missing).toEqual(["OPENAI_API_KEY"]); + }); + + it("identifies missing keys for claude-code runtime", () => { + const configured = new Set(); + const missing = findMissingKeys("claude-code", configured); + expect(missing).toEqual(["ANTHROPIC_API_KEY"]); + }); + + it("generates correct labels for modal display", () => { + const missing = findMissingKeys("langgraph", new Set()); + const labels = missing.map((k) => ({ key: k, label: getKeyLabel(k) })); + expect(labels).toEqual([ + { key: "OPENAI_API_KEY", label: "OpenAI API Key" }, + ]); + }); + + it("generates labels for claude-code missing keys", () => { + const missing = findMissingKeys("claude-code", new Set()); + const labels = missing.map((k) => ({ key: k, label: getKeyLabel(k) })); + expect(labels).toEqual([ + { key: "ANTHROPIC_API_KEY", label: "Anthropic API Key" }, + ]); + }); + + it("returns no missing keys when all are configured", () => { + const configured = new Set(["OPENAI_API_KEY"]); + const missing = findMissingKeys("langgraph", configured); + expect(missing).toEqual([]); + }); + + it("pre-deploy check returns ok=false and correct missing keys", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const result = await checkDeploySecrets("langgraph"); + expect(result.ok).toBe(false); + expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]); + expect(result.runtime).toBe("langgraph"); + }); + + it("pre-deploy check returns ok=true when keys are present", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" }, + ]), + } as Response); + + const result = await checkDeploySecrets("claude-code"); + expect(result.ok).toBe(true); + expect(result.missingKeys).toEqual([]); + }); + + it("modal data can be constructed from preflight result", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const result = await checkDeploySecrets("deepagents"); + // This is the data that would be passed to MissingKeysModal + const modalData = { + open: !result.ok, + missingKeys: result.missingKeys, + runtime: result.runtime, + }; + + expect(modalData.open).toBe(true); + expect(modalData.missingKeys).toEqual(["OPENAI_API_KEY"]); + expect(modalData.runtime).toBe("deepagents"); + }); + + it("handles all runtimes correctly for modal data construction", () => { + const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS); + for (const runtime of runtimes) { + const requiredKeys = getRequiredKeys(runtime); + const missing = findMissingKeys(runtime, new Set()); + const labels = missing.map((k) => getKeyLabel(k)); + + expect(requiredKeys.length).toBeGreaterThan(0); + expect(missing).toEqual(requiredKeys); + expect(labels.length).toBe(requiredKeys.length); + // Every label should be a non-empty string + for (const label of labels) { + expect(label.length).toBeGreaterThan(0); + } + } + }); + + it("save endpoint is correct for global scope", () => { + // Verify the endpoint that MissingKeysModal would call + const globalEndpoint = "/settings/secrets"; + expect(globalEndpoint).toBe("/settings/secrets"); + }); + + it("save endpoint is correct for workspace scope", () => { + const workspaceId = "ws-test-123"; + const wsEndpoint = `/workspaces/${workspaceId}/secrets`; + expect(wsEndpoint).toBe("/workspaces/ws-test-123/secrets"); + }); +}); diff --git a/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx b/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx new file mode 100644 index 00000000..432954aa --- /dev/null +++ b/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock fetch globally +global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response), +); + +import { useCanvasStore } from "../../store/canvas"; +import type { WorkspaceData } from "../../store/socket"; +import { DEFAULT_PROVISION_TIMEOUT_MS } from "../ProvisioningTimeout"; + +// Helper to build a WorkspaceData object +function makeWS(overrides: Partial & { id: string }): WorkspaceData { + return { + name: "WS", + role: "agent", + tier: 1, + status: "online", + agent_card: null, + url: "http://localhost:9000", + parent_id: null, + active_tasks: 0, + last_error_rate: 0, + last_sample_error: "", + uptime_seconds: 60, + current_task: "", + x: 0, + y: 0, + collapsed: false, + runtime: "", + ...overrides, + }; +} + +beforeEach(() => { + useCanvasStore.setState({ + nodes: [], + edges: [], + selectedNodeId: null, + panelTab: "details", + dragOverNodeId: null, + contextMenu: null, + searchOpen: false, + viewport: { x: 0, y: 0, zoom: 1 }, + }); + vi.clearAllMocks(); +}); + +describe("ProvisioningTimeout", () => { + it("exports the default timeout constant", () => { + expect(DEFAULT_PROVISION_TIMEOUT_MS).toBe(120_000); + }); + + it("can detect provisioning nodes in the store", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + makeWS({ id: "ws-2", name: "Agent 2", status: "online" }), + makeWS({ id: "ws-3", name: "Agent 3", status: "provisioning" }), + ]); + + const nodes = useCanvasStore.getState().nodes; + const provisioning = nodes.filter((n) => n.data.status === "provisioning"); + expect(provisioning).toHaveLength(2); + expect(provisioning.map((n) => n.id)).toEqual(["ws-1", "ws-3"]); + }); + + it("transitions node from provisioning to online on event", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + ]); + + useCanvasStore.getState().applyEvent({ + event: "WORKSPACE_ONLINE", + workspace_id: "ws-1", + timestamp: new Date().toISOString(), + payload: {}, + }); + + const node = useCanvasStore.getState().nodes.find((n) => n.id === "ws-1"); + expect(node?.data.status).toBe("online"); + }); + + it("transitions node from provisioning to failed on provision_failed event", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + ]); + + useCanvasStore.getState().applyEvent({ + event: "WORKSPACE_PROVISION_FAILED", + workspace_id: "ws-1", + timestamp: new Date().toISOString(), + payload: { error: "Docker daemon not running" }, + }); + + const node = useCanvasStore.getState().nodes.find((n) => n.id === "ws-1"); + expect(node?.data.status).toBe("failed"); + expect(node?.data.lastSampleError).toBe("Docker daemon not running"); + }); + + it("handles WORKSPACE_PROVISION_FAILED with no error message", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + ]); + + useCanvasStore.getState().applyEvent({ + event: "WORKSPACE_PROVISION_FAILED", + workspace_id: "ws-1", + timestamp: new Date().toISOString(), + payload: {}, + }); + + const node = useCanvasStore.getState().nodes.find((n) => n.id === "ws-1"); + expect(node?.data.status).toBe("failed"); + expect(node?.data.lastSampleError).toBe("Unknown provisioning error"); + }); + + it("restart API call can be made for provisioning recovery", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); + + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "failed" }), + ]); + + await useCanvasStore.getState().restartWorkspace("ws-1"); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/workspaces/ws-1/restart"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("node removal works for cancelling a failed deployment", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + makeWS({ id: "ws-2", name: "Agent 2", status: "online" }), + ]); + + expect(useCanvasStore.getState().nodes).toHaveLength(2); + + useCanvasStore.getState().removeNode("ws-1"); + + expect(useCanvasStore.getState().nodes).toHaveLength(1); + expect(useCanvasStore.getState().nodes[0].id).toBe("ws-2"); + }); + + it("selectNode and setPanelTab work for view logs action", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + ]); + + useCanvasStore.getState().selectNode("ws-1"); + expect(useCanvasStore.getState().selectedNodeId).toBe("ws-1"); + + useCanvasStore.getState().setPanelTab("terminal"); + expect(useCanvasStore.getState().panelTab).toBe("terminal"); + }); + + it("multiple provisioning nodes can coexist", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "ws-1", name: "Agent 1", status: "provisioning" }), + makeWS({ id: "ws-2", name: "Agent 2", status: "provisioning" }), + makeWS({ id: "ws-3", name: "Agent 3", status: "provisioning" }), + ]); + + const provisioning = useCanvasStore + .getState() + .nodes.filter((n) => n.data.status === "provisioning"); + expect(provisioning).toHaveLength(3); + + // First one goes online + useCanvasStore.getState().applyEvent({ + event: "WORKSPACE_ONLINE", + workspace_id: "ws-1", + timestamp: new Date().toISOString(), + payload: {}, + }); + + const stillProvisioning = useCanvasStore + .getState() + .nodes.filter((n) => n.data.status === "provisioning"); + expect(stillProvisioning).toHaveLength(2); + }); +}); diff --git a/canvas/src/components/__tests__/TemplatePalette.test.ts b/canvas/src/components/__tests__/TemplatePalette.test.ts new file mode 100644 index 00000000..63cffb3f --- /dev/null +++ b/canvas/src/components/__tests__/TemplatePalette.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +global.fetch = vi.fn(); + +import { + fetchOrgTemplates, + importOrgTemplate, + type OrgTemplate, +} from "../TemplatePalette"; + +const mockFetch = global.fetch as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("fetchOrgTemplates", () => { + it("returns the parsed list when the platform responds 200", async () => { + const sample: OrgTemplate[] = [ + { dir: "molecule-dev", name: "Molecule AI Dev Team", description: "PM + research + dev", workspaces: 11 }, + { dir: "reno-stars", name: "Reno Stars", description: "compact 6-agent team", workspaces: 6 }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => sample, + }); + const result = await fetchOrgTemplates(); + expect(result).toEqual(sample); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/org/templates"), + expect.objectContaining({ method: "GET" }) + ); + }); + + it("returns [] when the platform errors so the UI shows the empty state", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => "boom", + }); + expect(await fetchOrgTemplates()).toEqual([]); + }); + + it("returns [] when the network call rejects", async () => { + mockFetch.mockRejectedValueOnce(new Error("offline")); + expect(await fetchOrgTemplates()).toEqual([]); + }); +}); + +describe("importOrgTemplate", () => { + it("POSTs the dir to /org/import", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ created: 11 }) }); + await importOrgTemplate("molecule-dev"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/org/import"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ dir: "molecule-dev" }), + }) + ); + }); + + it("propagates platform errors so the caller can surface them", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => "org template not found: missing", + }); + await expect(importOrgTemplate("missing")).rejects.toThrow(/404.*not found/); + }); + + it("propagates network failures verbatim", async () => { + mockFetch.mockRejectedValueOnce(new Error("offline")); + await expect(importOrgTemplate("x")).rejects.toThrow("offline"); + }); +}); + +describe("module exports", () => { + it("exports the OrgTemplatesSection component", async () => { + const mod = await import("../TemplatePalette"); + expect(mod.OrgTemplatesSection).toBeDefined(); + expect(typeof mod.OrgTemplatesSection).toBe("function"); + }); +}); diff --git a/canvas/src/components/__tests__/buildTree.test.ts b/canvas/src/components/__tests__/buildTree.test.ts new file mode 100644 index 00000000..61d47f9f --- /dev/null +++ b/canvas/src/components/__tests__/buildTree.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { buildTree } from "../tabs/FilesTab"; + +describe("buildTree", () => { + it("returns empty array for empty input", () => { + expect(buildTree([])).toEqual([]); + }); + + it("handles flat files at root level", () => { + const files = [ + { path: "config.yaml", size: 100, dir: false }, + { path: "readme.md", size: 50, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(2); + expect(tree[0].name).toBe("config.yaml"); + expect(tree[1].name).toBe("readme.md"); + expect(tree.every((n) => !n.isDir)).toBe(true); + }); + + it("sorts dirs before files", () => { + const files = [ + { path: "file.txt", size: 10, dir: false }, + { path: "scripts", size: 0, dir: true }, + ]; + const tree = buildTree(files); + expect(tree[0].name).toBe("scripts"); + expect(tree[0].isDir).toBe(true); + expect(tree[1].name).toBe("file.txt"); + }); + + it("nests files under parent directories", () => { + const files = [ + { path: ".claude", size: 0, dir: true }, + { path: ".claude/settings.json", size: 200, dir: false }, + { path: ".claude/hooks", size: 0, dir: true }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + const claude = tree[0]; + expect(claude.name).toBe(".claude"); + expect(claude.isDir).toBe(true); + expect(claude.children).toHaveLength(2); + // dirs first in children + expect(claude.children[0].name).toBe("hooks"); + expect(claude.children[1].name).toBe("settings.json"); + }); + + it("does not duplicate dirs when both dir entry and nested children exist", () => { + // This is the key bug that was fixed — dir entry at root + nested child + // should NOT create two .claude nodes + const files = [ + { path: ".agents", size: 0, dir: true }, + { path: ".claude", size: 0, dir: true }, + { path: ".claude/settings.json", size: 767, dir: false }, + { path: ".claude/settings.local.json", size: 278, dir: false }, + ]; + const tree = buildTree(files); + const claudeNodes = tree.filter((n) => n.name === ".claude"); + expect(claudeNodes).toHaveLength(1); + expect(claudeNodes[0].children).toHaveLength(2); + }); + + it("creates implicit parent dirs for deeply nested files", () => { + const files = [ + { path: "src/lib/utils.ts", size: 300, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe("src"); + expect(tree[0].isDir).toBe(true); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].name).toBe("lib"); + expect(tree[0].children[0].children).toHaveLength(1); + expect(tree[0].children[0].children[0].name).toBe("utils.ts"); + }); + + it("handles nested dir entries without duplicating", () => { + // Nested dir entry like ".claude/.claude" scenario from lazy loading + const files = [ + { path: ".claude", size: 0, dir: true }, + { path: ".claude/.claude", size: 0, dir: true }, + { path: ".claude/.claude/settings.json", size: 100, dir: false }, + ]; + const tree = buildTree(files); + expect(tree).toHaveLength(1); + const outer = tree[0]; + expect(outer.name).toBe(".claude"); + const inner = outer.children.find((c) => c.name === ".claude"); + expect(inner).toBeDefined(); + expect(inner!.children).toHaveLength(1); + expect(inner!.children[0].name).toBe("settings.json"); + }); + + it("merges children when dir entry comes after nested files (sort order)", () => { + // Files arrive in any order — buildTree sorts dirs first + const files = [ + { path: "scripts/deploy.sh", size: 50, dir: false }, + { path: "scripts", size: 0, dir: true }, + ]; + const tree = buildTree(files); + const scriptsNodes = tree.filter((n) => n.name === "scripts"); + expect(scriptsNodes).toHaveLength(1); + expect(scriptsNodes[0].children).toHaveLength(1); + expect(scriptsNodes[0].children[0].name).toBe("deploy.sh"); + }); +}); diff --git a/canvas/src/components/canvas/TopBar.tsx b/canvas/src/components/canvas/TopBar.tsx new file mode 100644 index 00000000..5efb1a9d --- /dev/null +++ b/canvas/src/components/canvas/TopBar.tsx @@ -0,0 +1,33 @@ +import { SettingsButton } from '@/components/settings/SettingsButton'; +import { settingsGearRef } from '@/components/settings/SettingsPanel'; + +interface TopBarProps { + canvasName?: string; +} + +/** + * Canvas top bar component. + * + * Per spec §1.1, the gear icon sits in the right cluster: + * [Logo] [Canvas Name ▾] [+ New Agent] [⚙] [🔔] [Avatar] + * + * This is a minimal scaffold — the real TopBar in the canvas repo will + * already have the other elements. The integration point is adding + * into the right cluster. + */ +export function TopBar({ canvasName = 'Canvas' }: TopBarProps) { + return ( +
+
+ + {canvasName} +
+
+ + {/* === INTEGRATION POINT: Settings gear icon === */} + + {/* Bell and Avatar would go here */} +
+
+ ); +} diff --git a/canvas/src/components/settings/AddKeyForm.tsx b/canvas/src/components/settings/AddKeyForm.tsx new file mode 100644 index 00000000..97933ce1 --- /dev/null +++ b/canvas/src/components/settings/AddKeyForm.tsx @@ -0,0 +1,211 @@ +'use client'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { SecretGroup } from '@/types/secrets'; +import { useSecretsStore } from '@/stores/secrets-store'; +import { KeyValueField } from '@/components/ui/KeyValueField'; +import { ValidationHint } from '@/components/ui/ValidationHint'; +import { TestConnectionButton } from '@/components/ui/TestConnectionButton'; +import { + validateSecretValue, + isValidKeyName, + inferGroup, +} from '@/lib/validation/secret-formats'; +import { SERVICES, SERVICE_GROUP_ORDER, getDefaultKeyName } from '@/lib/services'; + +const VALIDATION_DEBOUNCE_MS = 400; + +interface AddKeyFormProps { + workspaceId: string; + existingNames: string[]; + onCancel: () => void; +} + +/** + * Inline-expanding form for adding a new API key. + * + * Flow (from spec §4.2): + * Form Open → select service → key name auto-fills → type value → + * optional Test Connection → Save + */ +export function AddKeyForm({ + workspaceId, + existingNames, + onCancel, +}: AddKeyFormProps) { + const createSecret = useSecretsStore((s) => s.createSecret); + + const [selectedGroup, setSelectedGroup] = useState('github'); + const [keyName, setKeyName] = useState(getDefaultKeyName('github')); + const [value, setValue] = useState(''); + const [validationError, setValidationError] = useState(null); + const [keyNameError, setKeyNameError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const debounceRef = useRef>(undefined); + const service = SERVICES[selectedGroup]; + + // Auto-fill key name when service changes + const handleServiceChange = useCallback( + (group: SecretGroup) => { + setSelectedGroup(group); + const defaultName = getDefaultKeyName(group); + if (defaultName) { + setKeyName(defaultName); + } + // Reset validation + setValidationError(null); + setKeyNameError(null); + setSaveError(null); + }, + [], + ); + + // Validate key name + useEffect(() => { + if (!keyName) { + setKeyNameError(null); + return; + } + if (!isValidKeyName(keyName)) { + setKeyNameError('Key name must be UPPER_SNAKE_CASE'); + return; + } + if (existingNames.includes(keyName)) { + setKeyNameError('A key named ' + keyName + ' already exists. Edit it instead.'); + return; + } + setKeyNameError(null); + }, [keyName, existingNames]); + + // Debounced value validation + useEffect(() => { + if (!value) { + setValidationError(null); + return; + } + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setValidationError(validateSecretValue(value, selectedGroup)); + }, VALIDATION_DEBOUNCE_MS); + return () => clearTimeout(debounceRef.current); + }, [value, selectedGroup]); + + const handleSave = useCallback(async () => { + // Final validation pass + if (!isValidKeyName(keyName)) { + setKeyNameError('Key name must be UPPER_SNAKE_CASE'); + return; + } + const valErr = validateSecretValue(value, selectedGroup); + if (valErr) { + setValidationError(valErr); + return; + } + + setIsSaving(true); + setSaveError(null); + try { + await createSecret(workspaceId, keyName, value); + // Form auto-closes via store (isAddFormOpen set to false) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to save. Check your connection and try again.'; + setSaveError(message); + } finally { + setIsSaving(false); + } + }, [keyName, value, selectedGroup, createSecret, workspaceId]); + + const canSave = keyName && value && !keyNameError && !validationError && !isSaving; + + return ( +
+
Add New Key
+ + {/* Service selector */} + + + {/* Key name */} + + {keyNameError && ( + + )} + + {/* Key value */} + + + 0} + /> + + {/* Test connection (only for supported services) */} + {service.testSupported && value && !validationError && ( + + )} + + {/* Save error */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/canvas/src/components/settings/DeleteConfirmDialog.tsx b/canvas/src/components/settings/DeleteConfirmDialog.tsx new file mode 100644 index 00000000..006b380a --- /dev/null +++ b/canvas/src/components/settings/DeleteConfirmDialog.tsx @@ -0,0 +1,152 @@ +'use client'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import * as AlertDialog from '@radix-ui/react-alert-dialog'; +import { useSecretsStore } from '@/stores/secrets-store'; +import { fetchDependents } from '@/lib/api/secrets'; + +const CONFIRM_DELAY_MS = 1_000; + +interface DeleteConfirmDialogProps { + workspaceId: string; +} + +/** + * Destructive confirmation dialog for deleting a secret key. + * + * Per spec §3.5 & §4.5: + * - Shows dependent agents (fetched live on open) + * - "Delete key" button disabled for 1s to prevent accidental double-click + * - Red/destructive styling + * - Focus-trapped (AlertDialog) + */ +export function DeleteConfirmDialog({ workspaceId }: DeleteConfirmDialogProps) { + const [secretName, setSecretName] = useState(null); + const [dependents, setDependents] = useState([]); + const [isLoadingDependents, setIsLoadingDependents] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [confirmEnabled, setConfirmEnabled] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const deleteSecret = useSecretsStore((s) => s.deleteSecret); + const confirmTimerRef = useRef>(undefined); + const abortRef = useRef(null); + + // Clean up timer + abort fetch on unmount + useEffect(() => { + return () => { + clearTimeout(confirmTimerRef.current); + abortRef.current?.abort(); + }; + }, []); + + // Listen for delete requests from SecretRow + useEffect(() => { + function handler(e: Event) { + const name = (e as CustomEvent).detail; + setSecretName(name); + setConfirmEnabled(false); + setDeleteError(null); + setDependents([]); + + // Fetch dependents (cancel previous if any) + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setIsLoadingDependents(true); + fetchDependents(workspaceId, name) + .then((deps) => { if (!controller.signal.aborted) setDependents(deps); }) + .catch(() => { if (!controller.signal.aborted) setDependents([]); }) + .finally(() => { if (!controller.signal.aborted) setIsLoadingDependents(false); }); + + // Enable confirm after 1s delay + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = setTimeout(() => setConfirmEnabled(true), CONFIRM_DELAY_MS); + } + window.addEventListener('secret:delete-request', handler); + return () => window.removeEventListener('secret:delete-request', handler); + }, [workspaceId]); + + const handleDelete = useCallback(async () => { + if (!secretName) return; + setIsDeleting(true); + setDeleteError(null); + try { + await deleteSecret(workspaceId, secretName); + setSecretName(null); + } catch (e) { + setDeleteError( + e instanceof Error ? e.message : 'Failed to delete key. Try again.', + ); + } finally { + setIsDeleting(false); + } + }, [secretName, deleteSecret, workspaceId]); + + const handleCancel = useCallback(() => { + setSecretName(null); + setDeleteError(null); + }, []); + + return ( + { if (!open) handleCancel(); }} + > + + + + + Delete “{secretName}”? + + + + This key will be permanently removed. + {isLoadingDependents && ' Checking for dependent agents…'} + + + {!isLoadingDependents && dependents.length > 0 && ( +
+

Agents that depend on it may stop working:

+
    + {dependents.map((d) => ( +
  • {d}
  • + ))} +
+
+ )} + + {!isLoadingDependents && dependents.length === 0 && ( +

+ No agents currently use this key. +

+ )} + +

This cannot be undone.

+ + {deleteError && ( +

+ {deleteError} +

+ )} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/canvas/src/components/settings/EmptyState.tsx b/canvas/src/components/settings/EmptyState.tsx new file mode 100644 index 00000000..e70595d9 --- /dev/null +++ b/canvas/src/components/settings/EmptyState.tsx @@ -0,0 +1,33 @@ +'use client'; + +interface EmptyStateProps { + onAddFirst: () => void; +} + +/** + * Shown when no secrets exist (replaces ServiceGroups). + * + * Per spec §3.2: + * 🔑 + * No API keys yet + * Add your API keys to let agents connect + * to GitHub, Anthropic, OpenRouter, and more. + * [+ Add your first API key] + */ +export function EmptyState({ onAddFirst }: EmptyStateProps) { + return ( +
+ +

No API keys yet

+

+ Add your API keys to let agents connect to GitHub, Anthropic, + OpenRouter, and more. +

+ +
+ ); +} diff --git a/canvas/src/components/settings/SearchBar.tsx b/canvas/src/components/settings/SearchBar.tsx new file mode 100644 index 00000000..c3d548d8 --- /dev/null +++ b/canvas/src/components/settings/SearchBar.tsx @@ -0,0 +1,57 @@ +'use client'; +import { useCallback, useRef, useEffect } from 'react'; +import { useSecretsStore } from '@/stores/secrets-store'; + +/** + * Client-side search/filter for secret key names. + * + * Per spec §9: + * - Shown only when ≥4 secrets exist + * - Filters KeyNameLabel text, case-insensitive, on every keystroke + * - Escape clears search (does NOT close panel) + * - Cmd+F / Ctrl+F focuses search when panel is open + */ +export function SearchBar() { + const searchQuery = useSecretsStore((s) => s.searchQuery); + const setSearchQuery = useSecretsStore((s) => s.setSearchQuery); + const inputRef = useRef(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); // Don't close panel + setSearchQuery(''); + inputRef.current?.blur(); + } + }, + [setSearchQuery], + ); + + // Cmd+F / Ctrl+F focuses search field + useEffect(() => { + function handler(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + inputRef.current?.focus(); + } + } + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + return ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search keys…" + className="search-bar__input" + aria-label="Search API keys" + /> +
+ ); +} diff --git a/canvas/src/components/settings/SecretRow.tsx b/canvas/src/components/settings/SecretRow.tsx new file mode 100644 index 00000000..92bb633e --- /dev/null +++ b/canvas/src/components/settings/SecretRow.tsx @@ -0,0 +1,225 @@ +'use client'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { Secret, SecretGroup } from '@/types/secrets'; +import { useSecretsStore } from '@/stores/secrets-store'; +import { StatusBadge } from '@/components/ui/StatusBadge'; +import { RevealToggle } from '@/components/ui/RevealToggle'; +import { KeyValueField } from '@/components/ui/KeyValueField'; +import { ValidationHint } from '@/components/ui/ValidationHint'; +import { TestConnectionButton } from '@/components/ui/TestConnectionButton'; +import { validateSecretValue } from '@/lib/validation/secret-formats'; +import { SERVICES } from '@/lib/services'; + +const AUTO_HIDE_MS = 30_000; +const VALIDATION_DEBOUNCE_MS = 400; + +interface SecretRowProps { + secret: Secret; + workspaceId: string; +} + +/** + * Single secret display row with masked value, status, and inline edit form. + * + * Display mode: key name | masked value | [reveal] [status] [copy] [edit] [delete] + * Edit mode: row expands to show value input + validation + test + save/cancel + */ +export function SecretRow({ secret, workspaceId }: SecretRowProps) { + const editingKey = useSecretsStore((s) => s.editingKey); + const setEditingKey = useSecretsStore((s) => s.setEditingKey); + const updateSecret = useSecretsStore((s) => s.updateSecret); + const setSecretStatus = useSecretsStore((s) => s.setSecretStatus); + + const isEditing = editingKey === secret.name; + const [revealed, setRevealed] = useState(false); + const [editValue, setEditValue] = useState(''); + const [validationError, setValidationError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const debounceRef = useRef>(undefined); + const editBtnRef = useRef(null); + const revealTimerRef = useRef>(undefined); + + // Auto-hide revealed value after 30s + useEffect(() => { + if (revealed) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS); + return () => clearTimeout(revealTimerRef.current); + } + }, [revealed]); + + // Reset revealed state when panel closes (session-only) + useEffect(() => { + return () => setRevealed(false); + }, []); + + // Debounced validation + useEffect(() => { + if (!isEditing || !editValue) { + setValidationError(null); + return; + } + debounceRef.current = setTimeout(() => { + setValidationError(validateSecretValue(editValue, secret.group)); + }, VALIDATION_DEBOUNCE_MS); + return () => clearTimeout(debounceRef.current); + }, [editValue, isEditing, secret.group]); + + const handleEdit = useCallback(() => { + setEditValue(''); + setSaveError(null); + setValidationError(null); + setEditingKey(secret.name); + }, [setEditingKey, secret.name]); + + const handleCancel = useCallback(() => { + setEditingKey(null); + setEditValue(''); + editBtnRef.current?.focus(); + }, [setEditingKey]); + + const handleSave = useCallback(async () => { + // Validate on submit + const err = validateSecretValue(editValue, secret.group); + if (err) { + setValidationError(err); + return; + } + setIsSaving(true); + setSaveError(null); + try { + await updateSecret(workspaceId, secret.name, editValue); + // Status resets to unverified after update (per spec §4.3) + setSecretStatus(secret.name, 'unverified'); + editBtnRef.current?.focus(); + } catch (e) { + setSaveError( + e instanceof Error ? e.message : 'Failed to save. Try again.', + ); + } finally { + setIsSaving(false); + } + }, [editValue, secret.group, secret.name, updateSecret, workspaceId, setSecretStatus]); + + const handleCopy = useCallback(async () => { + // Per spec: copy sends full value server-side when masked. + // For now, copy the masked value (real implementation would + // fetch plaintext from a dedicated endpoint). + await navigator.clipboard.writeText(secret.masked_value); + }, [secret.masked_value]); + + const handleDelete = useCallback(() => { + // Trigger delete flow — this is handled by parent via DeleteConfirmDialog + useSecretsStore.getState().setEditingKey(null); + // Emit custom event for DeleteConfirmDialog to pick up + window.dispatchEvent( + new CustomEvent('secret:delete-request', { detail: secret.name }), + ); + }, [secret.name]); + + const service = SERVICES[secret.group]; + + return ( +
+ {/* Display mode */} +
+ {secret.name} + + {secret.masked_value} + +
+ setRevealed((r) => !r)} + label={`Toggle reveal ${secret.name}`} + /> + + + + +
+
+ + {/* Edit mode — inline expand */} + {isEditing && ( +
+

+ Enter new value to replace — current value not shown for security +

+ + 0} + /> + {service.testSupported && editValue && !validationError && ( + + setSecretStatus(secret.name, valid ? 'verified' : 'invalid') + } + /> + )} + {saveError && ( +

+ {saveError} +

+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/canvas/src/components/settings/SecretsTab.tsx b/canvas/src/components/settings/SecretsTab.tsx new file mode 100644 index 00000000..a3cf3729 --- /dev/null +++ b/canvas/src/components/settings/SecretsTab.tsx @@ -0,0 +1,144 @@ +'use client'; +import { useMemo } from 'react'; +import type { Secret, SecretGroup } from '@/types/secrets'; +import { useSecretsStore } from '@/stores/secrets-store'; +import { SERVICES, SERVICE_GROUP_ORDER } from '@/lib/services'; +import { inferGroup } from '@/lib/validation/secret-formats'; +import { ServiceGroup } from './ServiceGroup'; +import { SearchBar } from './SearchBar'; +import { EmptyState } from './EmptyState'; +import { AddKeyForm } from './AddKeyForm'; + +interface SecretsTabProps { + workspaceId: string; +} + +/** + * Content of the "API Keys" tab inside SettingsPanel. + * Orchestrates SearchBar, ServiceGroups, AddKeyForm, and EmptyState. + */ +export function SecretsTab({ workspaceId }: SecretsTabProps) { + const secrets = useSecretsStore((s) => s.secrets); + const isLoading = useSecretsStore((s) => s.isLoading); + const error = useSecretsStore((s) => s.error); + const isAddFormOpen = useSecretsStore((s) => s.isAddFormOpen); + const setAddFormOpen = useSecretsStore((s) => s.setAddFormOpen); + const fetchSecrets = useSecretsStore((s) => s.fetchSecrets); + const searchQuery = useSecretsStore((s) => s.searchQuery); + + // Compute grouped locally with useMemo to avoid infinite re-renders. + // The old `useSecretsStore((s) => s.getGrouped())` created a new object + // reference on every selector call, breaking Zustand's referential equality check. + const grouped = useMemo(() => { + const q = searchQuery.toLowerCase(); + const filtered = q + ? secrets.filter((s) => s.name.toLowerCase().includes(q)) + : secrets; + const result = Object.fromEntries( + SERVICE_GROUP_ORDER.map((g) => [g, [] as Secret[]]), + ) as Record; + for (const secret of filtered) { + const group = secret.group ?? inferGroup(secret.name); + result[group].push(secret); + } + return result; + }, [secrets, searchQuery]); + + const SEARCH_THRESHOLD = 4; + const showSearch = secrets.length >= SEARCH_THRESHOLD; + + // Panel load error + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + // Loading + if (isLoading) { + return ( +
+ Loading API keys… +
+ ); + } + + // Empty state + if (secrets.length === 0) { + return ( + <> + setAddFormOpen(true)} /> + {isAddFormOpen && ( + setAddFormOpen(false)} + /> + )} + + ); + } + + // Check if search filtered everything out + const totalFiltered = Object.values(grouped).reduce( + (sum, arr) => sum + arr.length, + 0, + ); + + return ( +
+ {showSearch && } + + {totalFiltered === 0 && searchQuery && ( +
+ No keys match “{searchQuery}” + +
+ )} + + {SERVICE_GROUP_ORDER.map((groupKey) => { + const groupSecrets = grouped[groupKey]; + if (groupSecrets.length === 0) return null; + return ( + + ); + })} + +
+ {isAddFormOpen ? ( + s.name)} + onCancel={() => setAddFormOpen(false)} + /> + ) : ( + + )} +
+
+ ); +} diff --git a/canvas/src/components/settings/ServiceGroup.tsx b/canvas/src/components/settings/ServiceGroup.tsx new file mode 100644 index 00000000..b1bc4e29 --- /dev/null +++ b/canvas/src/components/settings/ServiceGroup.tsx @@ -0,0 +1,60 @@ +import type { Secret, SecretGroup, ServiceConfig } from '@/types/secrets'; +import { SecretRow } from './SecretRow'; + +interface ServiceGroupProps { + group: SecretGroup; + service: ServiceConfig; + secrets: Secret[]; + workspaceId: string; +} + +/** + * Collapsible group of secret rows under a service header. + * + * Per spec §3.1: + * ── GitHub ────────────────────────── 1 key ── + * GITHUB_TOKEN + * ghp-••••••••••••••xK9f [👁] [✓] [⎘] [✏] [🗑] + */ +export function ServiceGroup({ + group, + service, + secrets, + workspaceId, +}: ServiceGroupProps) { + const countLabel = secrets.length === 1 ? '1 key' : `${secrets.length} keys`; + + return ( +
+
+ + {service.label} + {countLabel} +
+
+ {secrets.map((secret) => ( + + ))} +
+
+ ); +} + +function ServiceIcon({ name }: { name: string }) { + // Placeholder — real implementation would use SVG imports or an icon component + const icons: Record = { + github: '🐙', + anthropic: '🤖', + openrouter: '🔀', + key: '🔑', + }; + return ( + + ); +} diff --git a/canvas/src/components/settings/SettingsButton.tsx b/canvas/src/components/settings/SettingsButton.tsx new file mode 100644 index 00000000..268c4e5d --- /dev/null +++ b/canvas/src/components/settings/SettingsButton.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { forwardRef } from 'react'; +import { useSecretsStore } from '@/stores/secrets-store'; +import * as Tooltip from '@radix-ui/react-tooltip'; + +/** + * Gear icon button for the top bar. Toggles the SettingsPanel. + * + * Per spec §1.1: + * - Position: right cluster of top bar, between bell and avatar + * - Icon: 20×20 gear/cog + * - Tooltip: "Settings ⌘," (300ms delay) + * - Active state: accent fill when panel is open + */ +export const SettingsButton = forwardRef( + function SettingsButton(_props, ref) { + const isPanelOpen = useSecretsStore((s) => s.isPanelOpen); + const openPanel = useSecretsStore((s) => s.openPanel); + const closePanel = useSecretsStore((s) => s.closePanel); + const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent); + + const handleClick = () => { + if (isPanelOpen) closePanel(); + else openPanel(); + }; + + return ( + + + + + + + + Settings {isMac ? '⌘,' : 'Ctrl+,'} + + + + + + ); + }, +); + +function GearIcon() { + return ( + + + + + ); +} diff --git a/canvas/src/components/settings/SettingsPanel.tsx b/canvas/src/components/settings/SettingsPanel.tsx new file mode 100644 index 00000000..13a10bb3 --- /dev/null +++ b/canvas/src/components/settings/SettingsPanel.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { createRef, useCallback, useEffect, useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import * as Tabs from '@radix-ui/react-tabs'; +import { useSecretsStore } from '@/stores/secrets-store'; +import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'; +import { SecretsTab } from './SecretsTab'; +import { UnsavedChangesGuard } from './UnsavedChangesGuard'; + +/** Module-level ref so TopBar's SettingsButton can receive focus back on close. */ +export const settingsGearRef = createRef(); + +interface SettingsPanelProps { + workspaceId: string; +} + +/** + * Right-anchored slide-over drawer (480px) for workspace settings. + * + * Per UX spec: + * - `aria-modal="false"` — canvas stays interactive behind the panel + * - 200ms ease-out slide animation (respects prefers-reduced-motion) + * - Backdrop: rgba(0,0,0,0.3), click to close (with unsaved guard) + * - Tabs: "API Keys" (active) | "General" (disabled placeholder) + * - Keyboard: Cmd+, / Ctrl+, toggles, Escape closes + */ +export function SettingsPanel({ workspaceId }: SettingsPanelProps) { + const isPanelOpen = useSecretsStore((s) => s.isPanelOpen); + const closePanel = useSecretsStore((s) => s.closePanel); + const openPanel = useSecretsStore((s) => s.openPanel); + const fetchSecrets = useSecretsStore((s) => s.fetchSecrets); + const isAddFormOpen = useSecretsStore((s) => s.isAddFormOpen); + const editingKey = useSecretsStore((s) => s.editingKey); + + const hasDirtyForm = isAddFormOpen || editingKey !== null; + + // Cmd+, / Ctrl+, toggle + const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.userAgent); + const toggle = useCallback(() => { + if (isPanelOpen) closePanel(); + else openPanel(); + }, [isPanelOpen, closePanel, openPanel]); + useKeyboardShortcut(',', toggle, { meta: isMac, ctrl: !isMac }); + + // Load secrets when panel opens + useEffect(() => { + if (isPanelOpen) { + fetchSecrets(workspaceId); + } + }, [isPanelOpen, fetchSecrets, workspaceId]); + + // Guard: track whether we should show unsaved-changes dialog + const [showGuard, setShowGuard] = useState(false); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && hasDirtyForm) { + setShowGuard(true); + return; + } + if (!open) { + closePanel(); + settingsGearRef.current?.focus(); + } + }, + [hasDirtyForm, closePanel], + ); + + const confirmDiscard = useCallback(() => { + setShowGuard(false); + closePanel(); + settingsGearRef.current?.focus(); + }, [closePanel]); + + return ( + <> + + + + { + if (hasDirtyForm) { + e.preventDefault(); + setShowGuard(true); + } + }} + > +
+ + Settings + + + + +
+ + + + + API Keys + + + General + + + + + + + + + {/* Future: General settings */} + + + +
+ + {isMac ? '⌘,' : 'Ctrl+,'} + + · + + Learn about secrets → + +
+
+
+
+ + setShowGuard(false)} + onDiscard={confirmDiscard} + /> + + ); +} + diff --git a/canvas/src/components/settings/UnsavedChangesGuard.tsx b/canvas/src/components/settings/UnsavedChangesGuard.tsx new file mode 100644 index 00000000..373716a3 --- /dev/null +++ b/canvas/src/components/settings/UnsavedChangesGuard.tsx @@ -0,0 +1,48 @@ +'use client'; + +import * as AlertDialog from '@radix-ui/react-alert-dialog'; + +interface UnsavedChangesGuardProps { + open: boolean; + onKeepEditing: () => void; + onDiscard: () => void; +} + +/** + * "Discard unsaved changes?" guard dialog. + * + * Per spec §4.4: + * - Shown when closing panel while a form has unsaved input + * - NOT shown if the form is empty (opened but nothing typed) + * - Focus-trapped (AlertDialog) + */ +export function UnsavedChangesGuard({ + open, + onKeepEditing, + onDiscard, +}: UnsavedChangesGuardProps) { + return ( + { if (!o) onKeepEditing(); }}> + + + + + Discard unsaved changes? + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/canvas/src/components/settings/index.ts b/canvas/src/components/settings/index.ts new file mode 100644 index 00000000..c1f86afb --- /dev/null +++ b/canvas/src/components/settings/index.ts @@ -0,0 +1,10 @@ +export { SettingsPanel } from './SettingsPanel'; +export { SettingsButton } from './SettingsButton'; +export { SecretsTab } from './SecretsTab'; +export { SecretRow } from './SecretRow'; +export { AddKeyForm } from './AddKeyForm'; +export { ServiceGroup } from './ServiceGroup'; +export { SearchBar } from './SearchBar'; +export { EmptyState } from './EmptyState'; +export { DeleteConfirmDialog } from './DeleteConfirmDialog'; +export { UnsavedChangesGuard } from './UnsavedChangesGuard'; diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx new file mode 100644 index 00000000..13f3c8e6 --- /dev/null +++ b/canvas/src/components/tabs/ActivityTab.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { ConversationTraceModal } from "@/components/ConversationTraceModal"; +import { type ActivityEntry } from "@/types/activity"; +import { useWorkspaceName } from "@/hooks/useWorkspaceName"; + +interface Props { + workspaceId: string; +} + +type FilterType = "all" | "a2a_receive" | "a2a_send" | "task_update" | "agent_log" | "skill_promotion" | "error"; + +const FILTERS: { id: FilterType; label: string; icon: string }[] = [ + { id: "all", label: "All", icon: "●" }, + { id: "a2a_receive", label: "A2A In", icon: "↙" }, + { id: "a2a_send", label: "A2A Out", icon: "↗" }, + { id: "task_update", label: "Tasks", icon: "◆" }, + { id: "skill_promotion", label: "Skill Promo", icon: "★" }, + { id: "agent_log", label: "Logs", icon: "▸" }, + { id: "error", label: "Errors", icon: "!" }, +]; + +const TYPE_COLORS: Record = { + a2a_receive: { text: "text-blue-400", bg: "bg-blue-950/30", border: "border-blue-800/30" }, + a2a_send: { text: "text-cyan-400", bg: "bg-cyan-950/30", border: "border-cyan-800/30" }, + task_update: { text: "text-amber-400", bg: "bg-amber-950/30", border: "border-amber-800/30" }, + skill_promotion: { text: "text-violet-300", bg: "bg-violet-950/30", border: "border-violet-800/30" }, + agent_log: { text: "text-zinc-400", bg: "bg-zinc-800/30", border: "border-zinc-700/30" }, + error: { text: "text-red-400", bg: "bg-red-950/30", border: "border-red-800/30" }, +}; + +const STATUS_ICONS: Record = { + ok: { icon: "✓", color: "text-emerald-400" }, + error: { icon: "✕", color: "text-red-400" }, + timeout: { icon: "⏱", color: "text-amber-400" }, +}; + +export function ActivityTab({ workspaceId }: Props) { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("all"); + const [expanded, setExpanded] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(true); + const [traceOpen, setTraceOpen] = useState(false); + const resolveName = useWorkspaceName(); + + const loadActivities = useCallback(async () => { + try { + const typeParam = filter !== "all" ? `?type=${filter}` : ""; + const data = await api.get(`/workspaces/${workspaceId}/activity${typeParam}`); + setActivities(data); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load activity"); + } finally { + setLoading(false); + } + }, [workspaceId, filter]); + + useEffect(() => { + setLoading(true); + loadActivities(); + }, [loadActivities]); + + useEffect(() => { + if (!autoRefresh) return; + const interval = setInterval(loadActivities, 5000); + return () => clearInterval(interval); + }, [loadActivities, autoRefresh]); + + return ( +
+ {/* Filter bar */} +
+
+ {FILTERS.map((f) => ( + + ))} +
+ + + +
+
+
+ {activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"} +
+
+ + {/* Activity list */} +
+ {loading && activities.length === 0 && ( +
Loading activity...
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && activities.length === 0 && ( +
+
No activity recorded yet
+
+ Activity logs appear when agents communicate or perform tasks +
+
+ )} + + {activities.map((entry) => ( + setExpanded(expanded === entry.id ? null : entry.id)} + resolveName={resolveName} + /> + ))} +
+ + setTraceOpen(false)} + /> +
+ ); +} + +function ActivityRow({ + entry, + expanded, + onToggle, + resolveName, +}: { + entry: ActivityEntry; + expanded: boolean; + onToggle: () => void; + resolveName: (id: string | null) => string; +}) { + const typeStyle = TYPE_COLORS[entry.activity_type] || TYPE_COLORS.agent_log; + const statusStyle = STATUS_ICONS[entry.status] || STATUS_ICONS.ok; + const isA2A = entry.activity_type.startsWith("a2a_"); + const isError = entry.status === "error"; + + return ( +
+ + + {/* Expanded details */} + {expanded && ( +
+ {entry.source_id && ( + + )} + {entry.target_id && ( + + )} + {/* Message preview — extract text from A2A request/response */} + {entry.request_body && ( + + )} + {entry.response_body && ( + + )} + {entry.error_detail && ( + + )} + {entry.request_body && ( + + )} + {entry.response_body && ( + + )} +
+ ID: {entry.id} +
+
+ )} +
+ ); +} + +/** Extract human-readable text from A2A request/response JSON */ +function MessagePreview({ label, body }: { label: string; body: Record }) { + // Try to extract text from A2A message parts + let text = ""; + try { + // Simple formats from MCP server: {task: "..."} or {result: "..."} + if (body.task && typeof body.task === "string") { text = body.task; } + if (!text && body.result && typeof body.result === "string") { text = body.result; } + if (text) { + return ( +
+
{label}
+
+ {text.slice(0, 2000)} +
+
+ ); + } + + // Request: params.message.parts[].text + const params = body.params as Record | undefined; + const message = params?.message as Record | undefined; + const parts = (message?.parts || []) as Array>; + text = parts + .map((p) => (p.text as string) || (p.kind === "text" ? (p.text as string) : "")) + .filter(Boolean) + .join("\n"); + + // Response: result.parts[].text + if (!text) { + const result = body.result as Record | undefined; + const rParts = (result?.parts || []) as Array>; + text = rParts + .map((p) => { + if (p.text) return p.text as string; + const root = p.root as Record | undefined; + return (root?.text as string) || ""; + }) + .filter(Boolean) + .join("\n"); + } + + // Fallback: result as string + if (!text && typeof body.result === "string") { + text = body.result; + } + } catch { + return null; + } + + if (!text) return null; + + return ( +
+
{label}
+
+ {text.slice(0, 2000)} +
+
+ ); +} + +function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function JsonBlock({ label, data }: { label: string; data: Record }) { + return ( +
+
{label}
+
+        {JSON.stringify(data, null, 2)}
+      
+
+ ); +} + +function formatType(type: string): string { + switch (type) { + case "a2a_receive": return "A2A IN"; + case "a2a_send": return "A2A OUT"; + case "task_update": return "TASK"; + case "skill_promotion": return "PROMO"; + case "agent_log": return "LOG"; + case "error": return "ERROR"; + default: return type.toUpperCase(); + } +} + +function formatTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + + if (diff < 60_000) return `${Math.floor(diff / 1000)}s`; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`; + return d.toLocaleDateString(); +} diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx new file mode 100644 index 00000000..68ddda4e --- /dev/null +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; + +interface ChannelAdapter { + type: string; + display_name: string; +} + +interface Channel { + id: string; + workspace_id: string; + channel_type: string; + config: Record; + enabled: boolean; + allowed_users: string[]; + message_count: number; + last_message_at?: string; + created_at: string; +} + +interface Props { + workspaceId: string; +} + +function relativeTime(iso: string | null | undefined): string { + if (!iso) return "never"; + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60000) return `${Math.round(diff / 1000)}s ago`; + if (diff < 3600000) return `${Math.round(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.round(diff / 3600000)}h ago`; + return `${Math.round(diff / 86400000)}d ago`; +} + +export function ChannelsTab({ workspaceId }: Props) { + const [channels, setChannels] = useState([]); + const [adapters, setAdapters] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [testing, setTesting] = useState(null); + + // Form state + const [formType, setFormType] = useState("telegram"); + const [formBotToken, setFormBotToken] = useState(""); + const [formChatId, setFormChatId] = useState(""); + const [formAllowedUsers, setFormAllowedUsers] = useState(""); + const [formError, setFormError] = useState(""); + const [discovering, setDiscovering] = useState(false); + const [discoveredChats, setDiscoveredChats] = useState<{ chat_id: string; name: string; type: string }[]>([]); + const [selectedChats, setSelectedChats] = useState>(new Set()); + const [showManualInput, setShowManualInput] = useState(false); + + const load = useCallback(async () => { + try { + const [chRes, adRes] = await Promise.all([ + api.get(`/workspaces/${workspaceId}/channels`), + api.get(`/channels/adapters`), + ]); + setChannels(Array.isArray(chRes) ? chRes : []); + setAdapters(Array.isArray(adRes) ? adRes : []); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }, [workspaceId]); + + useEffect(() => { load(); }, [load]); + + // Auto-refresh every 15s + useEffect(() => { + const interval = setInterval(load, 15000); + return () => clearInterval(interval); + }, [load]); + + const handleDiscover = async () => { + if (!formBotToken) { + setFormError("Enter a bot token first"); + return; + } + setDiscovering(true); + setFormError(""); + setDiscoveredChats([]); + try { + const res = await api.post<{ chats: { chat_id: string; name: string; type: string }[]; hint: string }>( + `/channels/discover`, + { channel_type: formType, bot_token: formBotToken } + ); + const chats = res.chats || []; + setDiscoveredChats(chats); + if (chats.length === 0) { + setFormError("No chats found. For groups: add the bot and send a message. For DMs: send /start to the bot first. Then retry."); + } else { + // Auto-select all discovered chats + setSelectedChats(new Set(chats.map((c) => c.chat_id))); + setFormChatId(chats.map((c) => c.chat_id).join(", ")); + } + } catch (e) { + setFormError(String(e)); + } finally { + setDiscovering(false); + } + }; + + const toggleChat = (chatId: string) => { + setSelectedChats((prev) => { + const next = new Set(prev); + if (next.has(chatId)) next.delete(chatId); + else next.add(chatId); + setFormChatId(Array.from(next).join(", ")); + return next; + }); + }; + + const handleCreate = async () => { + setFormError(""); + if (!formBotToken || !formChatId) { + setFormError("Bot token and chat ID are required"); + return; + } + try { + const allowed = formAllowedUsers + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + await api.post(`/workspaces/${workspaceId}/channels`, { + channel_type: formType, + config: { bot_token: formBotToken, chat_id: formChatId }, + allowed_users: allowed, + }); + setShowForm(false); + setFormBotToken(""); + setFormChatId(""); + setFormAllowedUsers(""); + load(); + } catch (e) { + setFormError(String(e)); + } + }; + + const handleToggle = async (ch: Channel) => { + await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, { + enabled: !ch.enabled, + }); + load(); + }; + + const handleDelete = async (ch: Channel) => { + if (!confirm(`Delete ${ch.channel_type} channel?`)) return; + await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`); + load(); + }; + + const handleTest = async (ch: Channel) => { + setTesting(ch.id); + try { + await api.post(`/workspaces/${workspaceId}/channels/${ch.id}/test`, {}); + } catch { + /* ignore — error shown on platform side */ + } finally { + setTimeout(() => setTesting(null), 2000); + } + }; + + if (loading) { + return ( +
Loading channels...
+ ); + } + + return ( +
+ {/* Header */} +
+

+ Channels +

+ +
+ + {/* Create form */} + {showForm && ( +
+
+ + +
+
+ + setFormBotToken(e.target.value)} + placeholder="123456:ABC-DEF..." + className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" + /> +
+
+
+ + +
+ {discoveredChats.length > 0 && ( +
+ {discoveredChats.map((chat) => ( + + ))} +
+ )} + {(discoveredChats.length === 0 || showManualInput) && ( + setFormChatId(e.target.value)} + placeholder="-100123456789, -100987654321" + className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" + /> + )} +

+ {discoveredChats.length > 0 ? ( + <> + Chats: {formChatId || "(none selected)"} + {" · "} + + + ) : ( + "Click Detect Chats after adding the bot to groups or sending /start in DMs." + )} +

+
+
+ + setFormAllowedUsers(e.target.value)} + placeholder="123456789, 987654321" + className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600" + /> +

+ Telegram user IDs. Leave empty to allow everyone. +

+
+ {formError && ( +

{formError}

+ )} + +
+ )} + + {/* Channel list */} + {channels.length === 0 && !showForm && ( +
+

No channels connected

+

+ Connect Telegram, Slack, or Discord to chat with this agent from social platforms. +

+
+ )} + + {channels.map((ch) => ( +
+
+
+ + + {ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)} + + + {ch.config.chat_id} + +
+
+ + + +
+
+
+ {ch.message_count} messages + Last: {relativeTime(ch.last_message_at)} + {ch.allowed_users.length > 0 && ( + {ch.allowed_users.length} allowed user(s) + )} +
+
+ ))} +
+ ); +} diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx new file mode 100644 index 00000000..b383fb06 --- /dev/null +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { api } from "@/lib/api"; +import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { WS_URL } from "@/store/socket"; +import { type ChatMessage, createMessage } from "./chat/types"; +import { extractResponseText, extractRequestText } from "./chat/message-parser"; +import { AgentCommsPanel } from "./chat/AgentCommsPanel"; +import { runtimeDisplayName } from "@/lib/runtime-names"; + +interface Props { + workspaceId: string; + data: WorkspaceNodeData; +} + +type ChatSubTab = "my-chat" | "agent-comms"; + +// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only +// need parts/artifacts text extraction for the synchronous fallback path. +interface A2APart { + kind: string; + text: string; +} +interface A2AResponse { + result?: { + parts?: A2APart[]; + artifacts?: Array<{ parts: A2APart[] }>; + }; +} + +// extractReplyText pulls the agent's text reply out of an A2A response. +// Mirrors the Go-side extractReplyText in platform/internal/channels/manager.go. +function extractReplyText(resp: A2AResponse): string { + const result = resp?.result; + if (result?.parts) { + for (const p of result.parts) { + if (p.kind === "text") return p.text; + } + } + if (result?.artifacts) { + for (const a of result.artifacts) { + for (const p of a.parts || []) { + if (p.kind === "text") return p.text; + } + } + } + return ""; +} + +/** + * Load chat history from the activity_logs database via the platform API. + * Uses source=canvas to only get user-initiated messages (not agent-to-agent). + */ +async function loadMessagesFromDB(workspaceId: string): Promise { + try { + const activities = await api.get | null; + response_body: Record | null; + }>>(`/workspaces/${workspaceId}/activity?type=a2a_receive&source=canvas&limit=50`); + + const messages: ChatMessage[] = []; + // Activities are newest-first, reverse for chronological order + for (const a of [...activities].reverse()) { + // Extract user message from request_body + const userText = extractRequestText(a.request_body); + if (userText) { + messages.push(createMessage("user", userText)); + } + + // Extract agent response + if (a.response_body) { + const text = extractResponseText(a.response_body); + if (text) { + const role = a.status === "error" || text.toLowerCase().startsWith("agent error") ? "system" : "agent"; + messages.push({ ...createMessage(role, text), timestamp: a.created_at }); + } + } + } + return messages; + } catch { + return []; + } +} + +/** + * ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel. + */ +export function ChatTab({ workspaceId, data }: Props) { + const [subTab, setSubTab] = useState("my-chat"); + + return ( +
+ {/* Sub-tab bar */} +
+ + +
+ {/* Content */} +
+ {subTab === "my-chat" ? ( + + ) : ( + + )} +
+
+ ); +} + +/** + * MyChatPanel — user↔agent conversation (extracted from original ChatTab). + */ +function MyChatPanel({ workspaceId, data }: Props) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(!!data.currentTask); + const [thinkingElapsed, setThinkingElapsed] = useState(0); + const [activityLog, setActivityLog] = useState([]); + const [loading, setLoading] = useState(true); + const currentTaskRef = useRef(data.currentTask); + const sendingFromAPIRef = useRef(false); + const [agentReachable, setAgentReachable] = useState(false); + const [error, setError] = useState(null); + const bottomRef = useRef(null); + + // Load chat history from database on mount + useEffect(() => { + setLoading(true); + loadMessagesFromDB(workspaceId).then((msgs) => { + setMessages(msgs); + setLoading(false); + }); + }, [workspaceId]); + + // Agent reachability + useEffect(() => { + const reachable = data.status === "online" || data.status === "degraded"; + setAgentReachable(reachable); + setError(reachable ? null : `Agent is ${data.status}`); + }, [data.status]); + + useEffect(() => { + currentTaskRef.current = data.currentTask; + }, [data.currentTask]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Consume agent push messages (send_message_to_user) from global store + const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]); + useEffect(() => { + if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return; + const consume = useCanvasStore.getState().consumeAgentMessages; + const msgs = consume(workspaceId); + for (const m of msgs) { + setMessages((prev) => [...prev, createMessage("agent", m.content)]); + } + }, [pendingAgentMsgs, workspaceId]); + + // Consume A2A_RESPONSE events from global store (streaming response delivery). + // Guarded by sendingFromAPIRef to avoid duplicate messages when the + // synchronous HTTP .then() handler also fires for the same response. + const pendingA2AResponse = useCanvasStore((s) => s.agentMessages[`a2a:${workspaceId}`]); + useEffect(() => { + if (!pendingA2AResponse || pendingA2AResponse.length === 0) return; + const consume = useCanvasStore.getState().consumeAgentMessages; + const msgs = consume(`a2a:${workspaceId}`); + if (!sendingFromAPIRef.current) return; // HTTP .then() already handled this response + for (const m of msgs) { + setMessages((prev) => [...prev, createMessage("agent", m.content)]); + } + setSending(false); + sendingFromAPIRef.current = false; + }, [pendingA2AResponse, workspaceId]); + + // Resolve workspace ID → name for activity display + const resolveWorkspaceName = useCallback((id: string) => { + const nodes = useCanvasStore.getState().nodes; + const node = nodes.find((n) => n.id === id); + return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8); + }, []); + + // Elapsed timer while sending + useEffect(() => { + if (!sending) { + setThinkingElapsed(0); + return; + } + const startTime = Date.now(); + const timer = setInterval(() => { + setThinkingElapsed(Math.floor((Date.now() - startTime) / 1000)); + }, 1000); + return () => clearInterval(timer); + }, [sending]); + + // Live activity feed via WebSocket while sending + useEffect(() => { + if (!sending) { + setActivityLog([]); + return; + } + setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]); + + const ws = new WebSocket(WS_URL); + ws.onerror = () => { + // Don't crash — activity feed is non-essential, just log + console.warn("ChatTab activity feed WS error"); + }; + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.event === "ACTIVITY_LOGGED") { + const p = msg.payload || {}; + const type = p.activity_type as string; + const method = (p.method as string) || ""; + const status = (p.status as string) || ""; + const targetId = (p.target_id as string) || ""; + const durationMs = p.duration_ms as number | undefined; + + let line = ""; + if (type === "a2a_receive" && method === "message/send") { + const targetName = resolveWorkspaceName(targetId || msg.workspace_id); + if (status === "ok" && durationMs) { + const sec = Math.round(durationMs / 1000); + line = `← ${targetName} responded (${sec}s)`; + } else if (status === "error") { + line = `⚠ ${targetName} error`; + } + } else if (type === "a2a_send") { + const targetName = resolveWorkspaceName(targetId); + line = `→ Delegating to ${targetName}...`; + } else if (type === "task_update") { + const summary = (p.summary as string) || ""; + if (summary) line = `⟳ ${summary}`; + } + + if (line) { + setActivityLog((prev) => [...prev.slice(-8), line]); + } + } else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) { + const task = (msg.payload?.current_task as string) || ""; + if (task) { + setActivityLog((prev) => [...prev.slice(-8), `⟳ ${task}`]); + } + } + // A2A_RESPONSE is handled by the store (pendingA2AResponse effect) — no duplicate here + } catch { /* ignore */ } + }; + + return () => ws.close(); + }, [sending, workspaceId, resolveWorkspaceName]); + + const sendMessage = async () => { + const text = input.trim(); + if (!text || !agentReachable || sending) return; + + setInput(""); + setMessages((prev) => [...prev, createMessage("user", text)]); + setSending(true); + sendingFromAPIRef.current = true; + setError(null); + + // Build conversation history from prior messages (last 20) + const history = messages + .filter((m) => m.role === "user" || m.role === "agent") + .slice(-20) + .map((m) => ({ + role: m.role === "user" ? "user" : "agent", + parts: [{ kind: "text", text: m.content }], + })); + + api.post(`/workspaces/${workspaceId}/a2a`, { + method: "message/send", + params: { + message: { + role: "user", + messageId: crypto.randomUUID(), + parts: [{ kind: "text", text }], + }, + metadata: { history }, + }, + }) + .then((resp) => { + // Skip if the WS A2A_RESPONSE event already handled this response. + // Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears + // it first wins, the other becomes a no-op (no duplicate messages). + if (!sendingFromAPIRef.current) return; + const replyText = extractReplyText(resp); + if (replyText) { + setMessages((prev) => [...prev, createMessage("agent", replyText)]); + } + setSending(false); + sendingFromAPIRef.current = false; + }) + .catch(() => { + setSending(false); + sendingFromAPIRef.current = false; + setError("Failed to send message — agent may be unreachable"); + }); + }; + + const isOnline = data.status === "online" || data.status === "degraded"; + + return ( +
+ {/* Messages */} +
+ {loading && ( +
Loading chat history...
+ )} + {!loading && messages.length === 0 && ( +
+ No messages yet. Send a message to start chatting with this agent. +
+ )} + {messages.map((msg) => ( +
+
+
+ {msg.content} +
+
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+
+ ))} + + {/* Thinking indicator */} + {sending && ( +
+
+
+ + + + + + {thinkingElapsed}s +
+ {activityLog.length > 0 && ( +
+
Processing with {runtimeDisplayName(data.runtime)}...
+ {activityLog.map((line, i) => ( +
◇ {line}
+ ))} +
+ )} +
+
+ )} +
+
+ + {/* Error banner */} + {error && ( +
+
+ {error} + {!isOnline && ( + + )} +
+
+ )} + + {/* Input */} +
+
+