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) <noreply@anthropic.com>
This commit is contained in:
commit
24fec62d7f
172
.agents/skills/code-review/SKILL.md
Normal file
172
.agents/skills/code-review/SKILL.md
Normal file
@ -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
|
||||
```
|
||||
60
.agents/skills/update-docs/SKILL.md
Normal file
60
.agents/skills/update-docs/SKILL.md
Normal file
@ -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
|
||||
22
.claude/CLAUDE_LOOP_NOTES.md
Normal file
22
.claude/CLAUDE_LOOP_NOTES.md
Normal file
@ -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).
|
||||
9
.claude/hooks/check-inbox.sh
Executable file
9
.claude/hooks/check-inbox.sh
Executable file
@ -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
|
||||
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
172
.claude/skills/code-review/SKILL.md
Normal file
172
.claude/skills/code-review/SKILL.md
Normal file
@ -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
|
||||
```
|
||||
1
.claude/skills/seo-audit
Symbolic link
1
.claude/skills/seo-audit
Symbolic link
@ -0,0 +1 @@
|
||||
../../.agents/skills/seo-audit
|
||||
89
.claude/skills/update-docs/SKILL.md
Normal file
89
.claude/skills/update-docs/SKILL.md
Normal file
@ -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
|
||||
65
.env.example
Normal file
65
.env.example
Normal file
@ -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:<port> 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
|
||||
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal file
@ -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
|
||||
124
.githooks/pre-commit
Executable file
124
.githooks/pre-commit
Executable file
@ -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
|
||||
104
.github/workflows/ci.yml
vendored
Normal file
104
.github/workflows/ci.yml
vendored
Normal file
@ -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
|
||||
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@ -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
|
||||
21
.mcp.json
Normal file
21
.mcp.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
AGENTS.md
Normal file
177
AGENTS.md
Normal file
@ -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_RULES_START -->
|
||||
# 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=<detailed natural language description>,
|
||||
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=<transcript>)
|
||||
|
||||
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.
|
||||
<!-- AWARENESS_RULES_END -->
|
||||
351
CLAUDE.md
Normal file
351
CLAUDE.md
Normal file
@ -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:<port>` 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.
|
||||
|
||||
<!-- AWARENESS_RULES_START -->
|
||||
# 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=<detailed natural language description>,
|
||||
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=<transcript>)
|
||||
|
||||
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=<detailed description>, insights={...}) IMMEDIATELY.
|
||||
|
||||
3. Is the user asking about past work? Call awareness_recall FIRST.
|
||||
<!-- AWARENESS_RULES_END -->
|
||||
45
LICENSE
Normal file
45
LICENSE
Normal file
@ -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
|
||||
304
PLAN.md
Normal file
304
PLAN.md
Normal file
@ -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 <token>` 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/<runtime>.py` into
|
||||
`workspace-template/plugins_registry/<plugin>/`. 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/<name>` 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.
|
||||
293
README.md
Normal file
293
README.md
Normal file
@ -0,0 +1,293 @@
|
||||
<div align="center">
|
||||
|
||||
<p>
|
||||
<img src="./docs/assets/branding/molecule-icon.png" alt="Molecule AI Icon Logo" width="160" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/branding/molecule-text-white.png">
|
||||
<img src="./docs/assets/branding/molecule-text-black.png" alt="Molecule AI Text Logo" width="420" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="./README.md">English</a> | <a href="./README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
|
||||
<h3>The Org-Native Control Plane For Heterogeneous AI Agent Teams</h3>
|
||||
|
||||
<p>
|
||||
The world's most powerful governance platform for AI agent teams.
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://www.python.org/)
|
||||
[](https://nextjs.org/)
|
||||
|
||||
<p>
|
||||
Visual Canvas • Runtime Compatibility • Hierarchical Memory • Skill Evolution • Operational Guardrails
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="./docs/index.md"><strong>Docs Home</strong></a> •
|
||||
<a href="./docs/quickstart.md"><strong>Quick Start</strong></a> •
|
||||
<a href="./docs/architecture/architecture.md"><strong>Architecture</strong></a> •
|
||||
<a href="./docs/api-protocol/platform-api.md"><strong>Platform API</strong></a> •
|
||||
<a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a>
|
||||
</p>
|
||||
|
||||
[](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
[](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
290
README.zh-CN.md
Normal file
290
README.zh-CN.md
Normal file
@ -0,0 +1,290 @@
|
||||
<div align="center">
|
||||
|
||||
<p>
|
||||
<img src="./docs/assets/branding/molecule-icon.png" alt="Molecule AI 图案 Logo" width="160" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/branding/molecule-text-white.png">
|
||||
<img src="./docs/assets/branding/molecule-text-black.png" alt="Molecule AI 文字 Logo" width="420" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="./README.md">English</a> | <a href="./README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
|
||||
<h3>面向异构 AI Agent 团队的组织级控制平面</h3>
|
||||
|
||||
<p>
|
||||
全球最强大的 Agent Team 治理方案。
|
||||
</p>
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://golang.org/)
|
||||
[](https://www.python.org/)
|
||||
[](https://nextjs.org/)
|
||||
|
||||
<p>
|
||||
Visual Canvas • Runtime Compatibility • Hierarchical Memory • Skill Evolution • Operational Guardrails
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="./docs/index.md"><strong>文档首页</strong></a> •
|
||||
<a href="./docs/quickstart.md"><strong>快速开始</strong></a> •
|
||||
<a href="./docs/architecture/architecture.md"><strong>系统架构</strong></a> •
|
||||
<a href="./docs/api-protocol/platform-api.md"><strong>Platform API</strong></a> •
|
||||
<a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a>
|
||||
</p>
|
||||
|
||||
[](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
[](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 一句话定位
|
||||
|
||||
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
|
||||
21
canvas/Dockerfile
Normal file
21
canvas/Dockerfile
Normal file
@ -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"]
|
||||
336
canvas/e2e/chat-separation.spec.ts
Normal file
336
canvas/e2e/chat-separation.spec.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
52
canvas/e2e/org-template-import.spec.ts
Normal file
52
canvas/e2e/org-template-import.spec.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
7
canvas/next.config.ts
Normal file
7
canvas/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5309
canvas/package-lock.json
generated
Normal file
5309
canvas/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
canvas/package.json
Normal file
42
canvas/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
17
canvas/playwright.config.ts
Normal file
17
canvas/playwright.config.ts
Normal file
@ -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" } },
|
||||
],
|
||||
});
|
||||
6
canvas/postcss.config.js
Normal file
6
canvas/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
canvas/public/.gitkeep
Normal file
0
canvas/public/.gitkeep
Normal file
BIN
canvas/public/molecule-icon.png
Normal file
BIN
canvas/public/molecule-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
222
canvas/src/app/__tests__/page-hydration.test.ts
Normal file
222
canvas/src/app/__tests__/page-hydration.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
112
canvas/src/app/globals.css
Normal file
112
canvas/src/app/globals.css
Normal file
@ -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;
|
||||
}
|
||||
BIN
canvas/src/app/icon.png
Normal file
BIN
canvas/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
19
canvas/src/app/layout.tsx
Normal file
19
canvas/src/app/layout.tsx
Normal file
@ -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 (
|
||||
<html lang="en">
|
||||
<body className="bg-zinc-950 text-white">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
42
canvas/src/app/page.tsx
Normal file
42
canvas/src/app/page.tsx
Normal file
@ -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<WorkspaceData[]>("/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 (
|
||||
<>
|
||||
<Canvas />
|
||||
<Legend />
|
||||
<CommunicationOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
canvas/src/components/ApprovalBanner.tsx
Normal file
89
canvas/src/components/ApprovalBanner.tsx
Normal file
@ -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<PendingApproval[]>([]);
|
||||
|
||||
// Single endpoint — no N+1 per-workspace polling
|
||||
const pollApprovals = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<PendingApproval[]>("/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 (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center">
|
||||
{approvals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="bg-amber-950/90 backdrop-blur-md border border-amber-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-amber-300 text-lg">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
|
||||
{approval.reason && (
|
||||
<div className="text-xs text-amber-300/70 mt-1">{approval.reason}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded-lg text-zinc-300 transition-colors"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
canvas/src/components/BundleDropZone.tsx
Normal file
114
canvas/src/components/BundleDropZone.tsx
Normal file
@ -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 */}
|
||||
<div
|
||||
className="fixed inset-0 z-10 pointer-events-none"
|
||||
style={{ pointerEvents: isDragging ? "auto" : "none" }}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* Global drag listener to detect file entering the window */}
|
||||
<div
|
||||
className="fixed inset-0 z-[5]"
|
||||
onDragOver={handleDragOver}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
|
||||
{/* Visual overlay when dragging */}
|
||||
{isDragging && (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
|
||||
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Importing spinner */}
|
||||
{importing && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-zinc-900/95 border border-zinc-700/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3">
|
||||
<div className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-zinc-200">Importing bundle...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result toast */}
|
||||
{result && (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl px-5 py-3 shadow-2xl text-sm ${
|
||||
result.status === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/50 text-emerald-200"
|
||||
: "bg-red-950/90 border border-red-700/50 text-red-200"
|
||||
}`}
|
||||
>
|
||||
{result.status === "success"
|
||||
? `Imported "${result.name}" successfully`
|
||||
: result.name}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
314
canvas/src/components/Canvas.tsx
Normal file
314
canvas/src/components/Canvas.tsx
Normal file
@ -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<Edge> = {
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: "#3f3f46",
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
export function Canvas() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const { getIntersectingNodes } = useReactFlow();
|
||||
|
||||
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||
(_event, node) => {
|
||||
dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = 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<Node<WorkspaceNodeData>> = 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<ReturnType<typeof setTimeout>>(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<ReturnType<typeof setTimeout>>(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 (
|
||||
<div className="w-screen h-screen bg-zinc-950">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onPaneClick={onPaneClick}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color="#27272a"
|
||||
/>
|
||||
<Controls
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
nodeColor={(node) => {
|
||||
const status = (node.data as Record<string, unknown>)?.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}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
<ContextMenu />
|
||||
<SearchDialog />
|
||||
<Toaster />
|
||||
{/* <ProvisioningTimeout /> */}
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
|
||||
{/* Confirmation dialog for structure changes */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingNest}
|
||||
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
|
||||
message={
|
||||
pendingNest?.targetId
|
||||
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
|
||||
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
|
||||
}
|
||||
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
|
||||
onConfirm={confirmNest}
|
||||
onCancel={cancelNest}
|
||||
/>
|
||||
|
||||
{/* Settings Panel — global secrets management drawer */}
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
canvas/src/components/CommunicationOverlay.tsx
Normal file
176
canvas/src/components/CommunicationOverlay.tsx
Normal file
@ -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<Communication[]>([]);
|
||||
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<Array<{
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
activity_type: string;
|
||||
source_id: string | null;
|
||||
target_id: string | null;
|
||||
summary: string | null;
|
||||
status: string;
|
||||
duration_ms: number | null;
|
||||
created_at: string;
|
||||
}>>(`/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<string>();
|
||||
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 (
|
||||
<button
|
||||
onClick={() => setVisible(true)}
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
title="Show communications"
|
||||
>
|
||||
↗↙ {comms.length > 0 ? `${comms.length} comms` : "Communications"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
|
||||
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
↗↙ Communications ({comms.length})
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setVisible(false)}
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[350px] p-2 space-y-1">
|
||||
{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 (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`rounded-lg px-2.5 py-1.5 text-[9px] border transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-950/30 border-blue-800/40"
|
||||
: "bg-zinc-800/30 border-zinc-700/20 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={typeColor}>{typeIcon}</span>
|
||||
<span className="text-zinc-300 font-medium truncate">
|
||||
{c.sourceName}
|
||||
</span>
|
||||
<span className="text-zinc-600">→</span>
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor}>{statusIcon}</span>
|
||||
<span className="text-zinc-600">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.summary && (
|
||||
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
)}
|
||||
{c.durationMs && (
|
||||
<div className="text-zinc-600 pl-4">{c.durationMs}ms</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
91
canvas/src/components/ConfirmDialog.tsx
Normal file
91
canvas/src/components/ConfirmDialog.tsx
Normal file
@ -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<HTMLDivElement>(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(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
274
canvas/src/components/ContextMenu.tsx
Normal file
274
canvas/src/components/ContextMenu.tsx
Normal file
@ -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<HTMLDivElement>(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<Record<string, unknown>>(`/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<Record<string, unknown>>(`/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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${
|
||||
isOnline ? "bg-emerald-400" : isOfflineOrFailed ? "bg-red-400" : "bg-zinc-500"
|
||||
}`} />
|
||||
<span className="text-[9px] text-zinc-500">{contextMenu.nodeData.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} className="h-px bg-zinc-800/60 my-1" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={item.action}
|
||||
disabled={item.disabled}
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
item.danger
|
||||
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
|
||||
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
title="Delete Workspace"
|
||||
message={`Permanently delete "${deleteConfirm?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => { setDeleteConfirm(null); closeContextMenu(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
canvas/src/components/ConversationTraceModal.tsx
Normal file
287
canvas/src/components/ConversationTraceModal.tsx
Normal file
@ -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<string, unknown> | 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<string, unknown> | undefined;
|
||||
const message = params?.message as Record<string, unknown> | undefined;
|
||||
const parts = (message?.parts || []) as Array<Record<string, unknown>>;
|
||||
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<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
const rText = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | 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<ActivityEntry[]>([]);
|
||||
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<ActivityEntry[]>(`/workspaces/${id}/activity?limit=200`)
|
||||
.catch(() => [] as ActivityEntry[])
|
||||
)
|
||||
).then((results) => {
|
||||
// Merge, deduplicate by ID, sort chronologically (oldest first)
|
||||
const seen = new Set<string>();
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Conversation Trace
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{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 (
|
||||
<div key={entry.id} className="group">
|
||||
{/* Event header */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Timeline dot + line */}
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
isError
|
||||
? "bg-red-500"
|
||||
: isSend
|
||||
? "bg-cyan-500"
|
||||
: isReceive
|
||||
? "bg-blue-500"
|
||||
: "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-zinc-600 font-mono">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
isError
|
||||
? "bg-red-950/50 text-red-400"
|
||||
: isSend
|
||||
? "bg-cyan-950/50 text-cyan-400"
|
||||
: isReceive
|
||||
? "bg-blue-950/50 text-blue-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isSend
|
||||
? "SEND"
|
||||
: isReceive
|
||||
? "RECEIVE"
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
{isA2A(entry) && (
|
||||
<div className="text-[11px] mt-1">
|
||||
{isSend ? (
|
||||
<span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
<span className="text-zinc-600"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName || wsName}
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-zinc-600">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{entry.summary && !isA2A(entry) && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1">
|
||||
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
|
||||
{entry.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[10px] text-red-400/80 mt-1 truncate">
|
||||
{entry.error_detail.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content — show request and/or response */}
|
||||
{requestText && (
|
||||
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-zinc-500 uppercase mb-1">
|
||||
{isSend ? "Task" : "Request"}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-zinc-600"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{responseText && (
|
||||
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-zinc-600"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
canvas/src/components/CreateWorkspaceDialog.tsx
Normal file
156
canvas/src/components/CreateWorkspaceDialog.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
New Workspace
|
||||
</button>
|
||||
|
||||
{open && <CreateDialog onClose={() => 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<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6">
|
||||
<h2 className="text-base font-semibold text-zinc-100 mb-1">Create Workspace</h2>
|
||||
<p className="text-xs text-zinc-500 mb-5">Add a new workspace node to the canvas</p>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<InputField label="Name" required value={name} onChange={setName} placeholder="e.g. SEO Agent" autoFocus />
|
||||
<InputField label="Role" value={role} onChange={setRole} placeholder="e.g. SEO Specialist" />
|
||||
<InputField label="Template" value={template} onChange={setTemplate} placeholder="e.g. seo-agent (from workspace-configs-templates/)" mono />
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">Tier</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTier(t.value)}
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
||||
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-mono font-semibold">{t.label}</div>
|
||||
<div className="text-[9px] mt-0.5 opacity-70">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputField label="Parent Workspace ID" value={parentId} onChange={setParentId} placeholder="Leave empty for root-level" mono />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
{label} {required && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => 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" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
canvas/src/components/EmptyState.tsx
Normal file
176
canvas/src/components/EmptyState.tsx
Normal file
@ -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<number, string> = {
|
||||
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<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deploying, setDeploying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Template[]>("/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 (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[1]">
|
||||
<div className="relative max-w-xl rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto">
|
||||
<div className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-blue-500/20 flex items-center justify-center">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="3" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
<rect x="15" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
<rect x="9" y="15" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
<path d="M8 13v2M20 13v4M14 13v2" stroke="#60a5fa" strokeWidth="1.5" strokeLinecap="round" opacity="0.45" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-sky-400/80 mb-2">
|
||||
Welcome to Molecule AI
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-zinc-100 mb-1">
|
||||
Deploy your first agent
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 mb-6 leading-relaxed">
|
||||
Pick a template to get started instantly, or create a blank workspace.
|
||||
</p>
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div className="text-xs text-zinc-600 py-4">Loading templates...</div>
|
||||
) : templates.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 mb-4 text-left max-h-[240px] overflow-y-auto">
|
||||
{templates.slice(0, 6).map((t) => {
|
||||
const tierColor = TIER_COLORS[t.tier] || TIER_COLORS[1];
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
{deploying === t.id ? "Deploying..." : t.name}
|
||||
</span>
|
||||
<span className={`text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md border ${tierColor}`}>
|
||||
T{t.tier}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 line-clamp-2 leading-relaxed">
|
||||
{t.description || "No description"}
|
||||
</p>
|
||||
{t.skill_count > 0 && (
|
||||
<p className="text-[9px] text-zinc-600 mt-1.5">
|
||||
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
|
||||
{t.model ? ` · ${t.model}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Create blank */}
|
||||
<button
|
||||
onClick={createBlank}
|
||||
disabled={!!deploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50"
|
||||
>
|
||||
{deploying === "blank" ? "Creating..." : "+ Create blank workspace"}
|
||||
</button>
|
||||
|
||||
{/* Org templates — instantiate a whole team in one click */}
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800/50 text-left">
|
||||
<OrgTemplatesSection />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-5 pt-4 border-t border-zinc-800/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-600">
|
||||
<span>Drag to nest workspaces into teams</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span>Right-click for actions</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span>Press <kbd className="px-1 py-0.5 bg-zinc-800 rounded text-zinc-500 font-mono">⌘K</kbd> to search</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
canvas/src/components/ErrorBoundary.tsx
Normal file
106
canvas/src/components/ErrorBoundary.tsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950 z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-zinc-900/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400 mb-1">
|
||||
An unexpected error occurred while rendering the application.
|
||||
</p>
|
||||
<p className="text-xs text-red-400/80 mb-6 font-mono break-all">
|
||||
{this.state.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<a
|
||||
href="#report"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
this.handleReport();
|
||||
}}
|
||||
className="rounded-lg border border-zinc-700 hover:border-zinc-600 px-5 py-2 text-sm font-medium text-zinc-300 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
70
canvas/src/components/Legend.tsx
Normal file
70
canvas/src/components/Legend.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
export function Legend() {
|
||||
return (
|
||||
<div className="fixed bottom-6 left-4 z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px]">
|
||||
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">Legend</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[9px] text-zinc-500 font-medium mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<StatusItem color="bg-emerald-400" label="Online" />
|
||||
<StatusItem color="bg-sky-400 animate-pulse" label="Starting" />
|
||||
<StatusItem color="bg-amber-400" label="Degraded" />
|
||||
<StatusItem color="bg-red-400" label="Failed" />
|
||||
<StatusItem color="bg-indigo-400" label="Paused" />
|
||||
<StatusItem color="bg-zinc-500" label="Offline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tiers */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[9px] text-zinc-500 font-medium mb-1">Tier</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<TierItem tier={1} label="Sandboxed" color="text-sky-300 bg-sky-950/40 border-sky-700/30" />
|
||||
<TierItem tier={2} label="Standard" color="text-violet-300 bg-violet-950/40 border-violet-700/30" />
|
||||
<TierItem tier={3} label="Full Access" color="text-amber-300 bg-amber-950/40 border-amber-700/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<div className="text-[9px] text-zinc-500 font-medium mb-1">Communication</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
|
||||
<CommItem icon="↙" color="text-blue-400" label="A2A In" />
|
||||
<CommItem icon="◆" color="text-amber-400" label="Task" />
|
||||
<CommItem icon="!" color="text-red-400" label="Error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusItem({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
|
||||
<span className="text-[8px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierItem({ tier, label, color }: { tier: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[8px] font-mono px-1 py-0.5 rounded border ${color}`}>T{tier}</span>
|
||||
<span className="text-[8px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommItem({ icon, color, label }: { icon: string; color: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[9px] ${color}`}>{icon}</span>
|
||||
<span className="text-[8px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
canvas/src/components/MissingKeysModal.tsx
Normal file
258
canvas/src/components/MissingKeysModal.tsx
Normal file
@ -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<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(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<KeyEntry>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 1L11 10H1L6 1Z"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Missing API Keys
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
|
||||
requires the following keys to be configured before deploying.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body — key list */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-300 font-medium">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-zinc-600">
|
||||
{entry.key}
|
||||
</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{globalError && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-red-400">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
canvas/src/components/OnboardingWizard.tsx
Normal file
185
canvas/src/components/OnboardingWizard.tsx
Normal file
@ -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<Step>("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 (
|
||||
<div className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-zinc-800">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
|
||||
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
|
||||
Step {currentStepIdx + 1} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
Skip guide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-sm font-medium text-zinc-100 mb-1">
|
||||
{currentStep.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-zinc-400 leading-relaxed mb-3">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
>
|
||||
{step === "welcome"
|
||||
? "Create Workspace"
|
||||
: step === "api-key"
|
||||
? "Open Config"
|
||||
: step === "send-message"
|
||||
? "Open Chat"
|
||||
: "Get Started"}
|
||||
</button>
|
||||
{step !== "done" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = STEPS[currentStepIdx + 1];
|
||||
if (next) setStep(next.id);
|
||||
else dismiss();
|
||||
}}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-[11px] text-zinc-400 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
canvas/src/components/ProvisioningTimeout.tsx
Normal file
283
canvas/src/components/ProvisioningTimeout.tsx
Normal file
@ -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<TimeoutEntry[]>([]);
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set());
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const trackingRef = useRef<Map<string, number>>(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<Set<string>>(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<string | null>(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 (
|
||||
<div role="alert" aria-live="assertive" className="fixed top-14 left-1/2 -translate-x-1/2 z-40 flex flex-col gap-2 max-w-[480px] w-full px-4">
|
||||
{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 (
|
||||
<div
|
||||
key={entry.workspaceId}
|
||||
className="bg-amber-950/90 border border-amber-700/40 rounded-xl px-4 py-3 shadow-2xl shadow-black/40 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Warning icon */}
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 2L14 13H2L8 2Z"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M8 7V9.5" stroke="#fbbf24" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<circle cx="8" cy="11" r="0.6" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-semibold text-amber-200 mb-0.5">
|
||||
Provisioning Timeout
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-300/80 leading-relaxed">
|
||||
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
|
||||
has been provisioning for{" "}
|
||||
<span className="font-mono text-amber-300">{formatDuration(elapsed)}</span>.
|
||||
It may have encountered an issue.
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
View Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Cancel confirmation dialog */}
|
||||
{confirmingCancel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
|
||||
Cancel deployment?
|
||||
</h3>
|
||||
<p className="text-[12px] text-zinc-400 mb-4 leading-relaxed">
|
||||
This will permanently remove the workspace. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Remove Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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`;
|
||||
}
|
||||
121
canvas/src/components/SearchDialog.tsx
Normal file
121
canvas/src/components/SearchDialog.tsx
Normal file
@ -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<HTMLInputElement>(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 (
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none"
|
||||
/>
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
{query ? "No workspaces match" : "No workspaces yet"}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className="w-full px-4 py-2.5 flex items-center gap-3 text-left hover:bg-zinc-800/40 transition-colors"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
node.data.status === "online" ? "bg-emerald-400" :
|
||||
node.data.status === "failed" ? "bg-red-400" :
|
||||
node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" :
|
||||
"bg-zinc-500"
|
||||
}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
|
||||
{node.data.role && (
|
||||
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-zinc-600">T{node.data.tier}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-600">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
canvas/src/components/SidePanel.tsx
Normal file
224
canvas/src/components/SidePanel.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full bg-zinc-950/95 backdrop-blur-xl border-l border-zinc-800/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800/40 bg-zinc-900/30">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative">
|
||||
<StatusDot status={node.data.status} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[14px] font-semibold text-zinc-100 truncate leading-tight">
|
||||
{node.data.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{node.data.role && (
|
||||
<span className="text-[10px] text-zinc-500 truncate">
|
||||
{node.data.role}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
|
||||
isOnline ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500 bg-zinc-800/50"
|
||||
}`}>
|
||||
T{node.data.tier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => selectNode(null)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Capability summary */}
|
||||
<div className="px-5 py-3 border-b border-zinc-800/40 bg-zinc-900/20">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MetaPill label="Tier" value={`T${node.data.tier}`} />
|
||||
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
||||
<MetaPill label="Skills" value={capability.skillCount > 0 ? `${capability.skillCount}` : "none"} />
|
||||
<MetaPill label="Status" value={node.data.status} tone={isOnline ? "emerald" : "zinc"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setPanelTab(tab.id)}
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 ${
|
||||
panelTab === tab.id
|
||||
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-50">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Needs Restart Banner */}
|
||||
{node.data.needsRestart && !node.data.currentTask && selectedNodeId && (
|
||||
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
||||
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="text-[9px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors"
|
||||
>
|
||||
Restart Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Task Banner */}
|
||||
{node.data.currentTask && (
|
||||
<Tooltip text={node.data.currentTask as string}>
|
||||
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300/90 truncate">
|
||||
{node.data.currentTask}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{panelTab === "details" && <DetailsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "skills" && <SkillsTab key={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "memory" && <MemoryTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
</div>
|
||||
|
||||
{/* Footer — workspace ID */}
|
||||
<div className="px-5 py-2 border-t border-zinc-800/40 bg-zinc-900/20">
|
||||
<span className="text-[9px] font-mono text-zinc-600 select-all">
|
||||
{selectedNodeId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[9px] ${toneClasses}`}>
|
||||
<span className="uppercase tracking-[0.18em] text-[8px] opacity-70">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
26
canvas/src/components/StatusDot.tsx
Normal file
26
canvas/src/components/StatusDot.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full shrink-0 ${STATUS_COLORS[status] || "bg-zinc-600"} ${glowClass}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
415
canvas/src/components/TemplatePalette.tsx
Normal file
415
canvas/src/components/TemplatePalette.tsx
Normal file
@ -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<OrgTemplate[]> {
|
||||
try {
|
||||
return await api.get<OrgTemplate[]>("/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<void> {
|
||||
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<OrgTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-2" data-testid="org-templates-section">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold">
|
||||
Org Templates
|
||||
</h3>
|
||||
<button
|
||||
onClick={loadOrgs}
|
||||
className="text-[9px] text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-[10px] text-zinc-500">Loading…</div>}
|
||||
|
||||
{!loading && orgs.length === 0 && (
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
No org templates in <code>org-templates/</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgs.map((o) => {
|
||||
const isImporting = importing === o.dir;
|
||||
return (
|
||||
<div
|
||||
key={o.dir}
|
||||
className="bg-zinc-800/30 border border-zinc-700/40 rounded-lg p-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[11px] font-semibold text-zinc-200 truncate">
|
||||
{o.name || o.dir}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-violet-400 bg-violet-950/40 px-1.5 py-0.5 rounded shrink-0">
|
||||
{o.workspaces}w
|
||||
</span>
|
||||
</div>
|
||||
{o.description && (
|
||||
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
|
||||
{o.description}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleImport(o)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-2 py-1 bg-violet-600/20 hover:bg-violet-600/30 border border-violet-500/30 rounded text-[10px] text-violet-300 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isImporting ? "Importing…" : "Import org"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<number, { label: string; color: string }> = {
|
||||
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<HTMLInputElement>(null);
|
||||
|
||||
const handleFiles = async (fileList: FileList) => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const files: Record<string, string> = {};
|
||||
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 (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importing ? "Importing..." : "Import Agent Folder"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplatePalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<Template[]>("/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<string, string> = {
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
open
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-900/90 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600"
|
||||
}`}
|
||||
title="Template Palette"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Missing Keys Modal */}
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
onKeysAdded={() => {
|
||||
if (missingKeysInfo) {
|
||||
const template = missingKeysInfo.template;
|
||||
setMissingKeysInfo(null);
|
||||
executeDeploy(template);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMissingKeysInfo(null)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
{open && (
|
||||
<div className="fixed top-0 left-0 h-full w-[280px] bg-zinc-900/95 backdrop-blur-md border-r border-zinc-800/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
||||
<div className="px-4 pt-14 pb-3 border-b border-zinc-800/60">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Templates</h2>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">Click to deploy a workspace</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">Loading...</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No templates found in<br />workspace-configs-templates/
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templates.map((t) => {
|
||||
const tierCfg = TIER_LABELS[t.tier] || TIER_LABELS[1];
|
||||
const isDeploying = creating === t.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-semibold text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
{t.name}
|
||||
</span>
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded-md shrink-0 ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{t.skills?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.skills.slice(0, 3).map((s) => (
|
||||
<span key={s} className="text-[8px] text-zinc-400 bg-zinc-700/40 px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
{t.skills.length > 3 && (
|
||||
<span className="text-[8px] text-zinc-500">+{t.skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDeploying && (
|
||||
<div className="text-[10px] text-sky-400 mt-1.5 animate-pulse">Deploying...</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<OrgTemplatesSection />
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
onClick={loadTemplates}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
|
||||
>
|
||||
Refresh templates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
canvas/src/components/Toaster.tsx
Normal file
52
canvas/src/components/Toaster.tsx
Normal file
@ -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<Toast[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
toast.type === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||||
: toast.type === "error"
|
||||
? "bg-red-950/90 border border-red-700/40 text-red-200"
|
||||
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
canvas/src/components/Toolbar.tsx
Normal file
243
canvas/src/components/Toolbar.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20">
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-2 pr-3 border-r border-zinc-800/60">
|
||||
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
|
||||
<span className="text-[11px] font-semibold text-zinc-300 tracking-wide">Molecule AI</span>
|
||||
</div>
|
||||
|
||||
{/* Status counts */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<StatusPill color="bg-emerald-400" count={counts.online} label="online" />
|
||||
{counts.offline > 0 && (
|
||||
<StatusPill color="bg-zinc-500" count={counts.offline} label="offline" />
|
||||
)}
|
||||
{counts.provisioning > 0 && (
|
||||
<StatusPill color="bg-sky-400 animate-pulse" count={counts.provisioning} label="starting" />
|
||||
)}
|
||||
{counts.failed > 0 && (
|
||||
<StatusPill color="bg-red-400" count={counts.failed} label="failed" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="pl-3 border-l border-zinc-800/60">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
|
||||
{counts.children > 0 && <span className="text-zinc-600"> + {counts.children} sub</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stop All — visible when agents have active tasks */}
|
||||
{counts.activeTasks > 0 && (
|
||||
<button
|
||||
onClick={stopAll}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={`Stop all running tasks (${counts.activeTasks} active)`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-red-400">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-red-300 font-medium">
|
||||
{stopping ? "Stopping..." : `Stop All (${counts.activeTasks})`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
|
||||
{needsRestartNodes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
||||
<path d="M2 8a6 6 0 1 1 1.76 4.24M2 13v-3h3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-amber-300 font-medium">
|
||||
{restartingAll ? "Restarting..." : `Restart Pending (${needsRestartNodes.length})`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search shortcut */}
|
||||
<button
|
||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-zinc-500">Search</span>
|
||||
<kbd className="text-[8px] text-zinc-600 bg-zinc-900/60 px-1 py-0.5 rounded border border-zinc-700/30">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Quick help */}
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open quick help"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500">
|
||||
<path d="M8 12v.5M6.5 6.3A1.9 1.9 0 1 1 9 8.1c-.7.4-1 .8-1 1.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-zinc-500">Help</span>
|
||||
</button>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-zinc-700/60 bg-zinc-950/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
|
||||
<button
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
|
||||
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
|
||||
<HelpRow shortcut="Right-click" text="Use node actions for expand, duplicate, export, restart, or delete." />
|
||||
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
|
||||
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<SettingsButton ref={settingsGearRef} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={restartConfirmOpen}
|
||||
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"}?`}
|
||||
message="These workspaces have pending config or secret changes that need a restart to take effect."
|
||||
confirmLabel="Restart"
|
||||
confirmVariant="warning"
|
||||
onConfirm={restartAll}
|
||||
onCancel={() => setRestartConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ color, count, label }: { color: string; count: number; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={`${count} ${label}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
|
||||
<span className="shrink-0 rounded-md border border-zinc-700/60 bg-zinc-950/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-zinc-400">
|
||||
{shortcut}
|
||||
</span>
|
||||
<p className="text-[11px] leading-relaxed text-zinc-500">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
canvas/src/components/Tooltip.tsx
Normal file
50
canvas/src/components/Tooltip.tsx
Normal file
@ -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<ReturnType<typeof setTimeout>>(undefined);
|
||||
const triggerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={triggerRef} onMouseEnter={enter} onMouseLeave={leave}>
|
||||
{children}
|
||||
{show && text && createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg shadow-2xl shadow-black/60 pointer-events-none"
|
||||
style={{ left: pos.x, top: Math.max(8, pos.y - 8), transform: "translateY(-100%)" }}
|
||||
>
|
||||
<div className="text-[11px] text-zinc-200 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
468
canvas/src/components/WorkspaceNode.tsx
Normal file
468
canvas/src/components/WorkspaceNode.tsx
Normal file
@ -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<string, { dot: string; glow: string; label: string; bar: string }> = {
|
||||
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 (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7L7 3" />
|
||||
<path d="M4 3H7V6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TIER_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
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<Node<WorkspaceNodeData>>) {
|
||||
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 (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
<div className={`absolute inset-x-0 top-0 h-8 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
/>
|
||||
|
||||
<div className="relative px-3.5 py-2.5">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<span className="text-[13px] font-semibold text-zinc-100 truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasChildren && (
|
||||
<span className="text-[8px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1.5 py-0.5 rounded-md">
|
||||
{descendantCount} sub
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded-md ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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<string, unknown>).runtime === "string"
|
||||
? (data.agentCard as Record<string, string>).runtime
|
||||
: null;
|
||||
const runtime = dbRuntime ?? cardRuntime;
|
||||
if (!runtime) return null;
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{runtime === "external" ? (
|
||||
<span
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-violet-200 bg-violet-900/50 border border-violet-500/40"
|
||||
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
|
||||
>
|
||||
★ REMOTE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-zinc-400 bg-zinc-800/60 border border-zinc-700/30">
|
||||
{runtime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{skills.slice(0, 4).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={`text-[8px] px-1.5 py-0.5 rounded-md border ${
|
||||
isOnline
|
||||
? "text-emerald-300/80 bg-emerald-950/30 border-emerald-800/30"
|
||||
: "text-zinc-400 bg-zinc-800/60 border-zinc-700/40"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 4 && (
|
||||
<span className="text-[8px] text-zinc-500 self-center">
|
||||
+{skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedded children — rendered INSIDE the parent node */}
|
||||
{hasChildren && (
|
||||
<EmbeddedTeam members={children} depth={0} onSelect={selectNode} onExtract={handleExtract} />
|
||||
)}
|
||||
|
||||
{/* Current task */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1.5 mt-1 bg-amber-950/20 px-2 py-1 rounded-md border border-amber-800/20 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<span className="text-[8px] text-amber-300/80 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Needs restart banner */}
|
||||
{data.needsRestart && !data.currentTask && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="flex items-center gap-1.5 mt-1 w-full bg-sky-950/30 px-2 py-1 rounded-md border border-sky-800/30 hover:bg-sky-900/40 transition-colors text-left"
|
||||
>
|
||||
<span className="text-[8px]">↻</span>
|
||||
<span className="text-[8px] text-sky-300/80">Restart to apply changes</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bottom row: status / active tasks */}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{data.status !== "online" ? (
|
||||
<div className={`text-[8px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-400" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</div>
|
||||
) : <div />}
|
||||
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span className="text-[8px] text-amber-300/80 tabular-nums">
|
||||
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Degraded error preview */}
|
||||
{data.status === "degraded" && data.lastSampleError && (
|
||||
<div
|
||||
className="text-[8px] text-amber-300/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
|
||||
title={data.lastSampleError}
|
||||
>
|
||||
{data.lastSampleError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_NESTING_DEPTH = 3;
|
||||
|
||||
/** Count all descendants (children + grandchildren + ...) */
|
||||
function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], visited = new Set<string>()): 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<WorkspaceNodeData>[];
|
||||
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 (
|
||||
<div className="mt-2 pt-2 border-t border-zinc-700/30">
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-widest mb-1.5">Team Members</div>
|
||||
<div className={useGrid
|
||||
? "grid grid-cols-2 gap-1.5 lg:grid-cols-3"
|
||||
: "space-y-1.5"
|
||||
}>
|
||||
{members.map((child) => (
|
||||
<TeamMemberChip key={child.id} node={child} allNodes={allNodes} depth={depth} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Recursive mini-card — mirrors parent card layout at smaller scale */
|
||||
function TeamMemberChip({
|
||||
node,
|
||||
allNodes,
|
||||
depth,
|
||||
onSelect,
|
||||
onExtract,
|
||||
}: {
|
||||
node: Node<WorkspaceNodeData>;
|
||||
allNodes: Node<WorkspaceNodeData>[];
|
||||
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 (
|
||||
<div
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer"
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
|
||||
|
||||
<div className="relative px-2 py-1.5">
|
||||
{/* Header: name + badges + extract */}
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
|
||||
<span className="text-[9px] font-semibold text-zinc-200 truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[7px] font-mono px-1 py-0.5 rounded ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
title="Extract from team"
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all"
|
||||
>
|
||||
<EjectIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[8px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mb-1">
|
||||
{skills.slice(0, 3).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className={`text-[7px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 3 && (
|
||||
<span className="text-[7px] text-zinc-500 self-center">+{skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status + active tasks row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{data.status !== "online" ? (
|
||||
<span className={`text-[7px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-400" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
) : <div />}
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span className="text-[7px] text-amber-300/80 tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current task banner for sub-agents */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<span className="text-[7px] text-amber-300/70 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recursive sub-children rendered inside this card */}
|
||||
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
|
||||
<div className="text-[7px] text-zinc-600 uppercase tracking-widest mb-1">Team</div>
|
||||
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
|
||||
{subChildren.map((sub) => (
|
||||
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
|
||||
if (!agentCard) return [];
|
||||
const skills = agentCard.skills;
|
||||
if (!Array.isArray(skills)) return [];
|
||||
return skills.map((s: Record<string, unknown>) =>
|
||||
String(s.name || s.id || "")
|
||||
).filter(Boolean);
|
||||
}
|
||||
127
canvas/src/components/__tests__/ErrorBoundary.test.tsx
Normal file
127
canvas/src/components/__tests__/ErrorBoundary.test.tsx
Normal file
@ -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<typeof vi.spyOn>;
|
||||
|
||||
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: "<App>\n <Child>" } as React.ErrorInfo;
|
||||
|
||||
instance.componentDidCatch(err, info);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"ErrorBoundary caught an error:",
|
||||
err,
|
||||
"<App>\n <Child>"
|
||||
);
|
||||
});
|
||||
|
||||
// ---- 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");
|
||||
});
|
||||
});
|
||||
135
canvas/src/components/__tests__/MissingKeysModal.test.tsx
Normal file
135
canvas/src/components/__tests__/MissingKeysModal.test.tsx
Normal file
@ -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<string>();
|
||||
const missing = findMissingKeys("langgraph", configured);
|
||||
expect(missing).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
|
||||
it("identifies missing keys for claude-code runtime", () => {
|
||||
const configured = new Set<string>();
|
||||
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<string>());
|
||||
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<string>());
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string>());
|
||||
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");
|
||||
});
|
||||
});
|
||||
186
canvas/src/components/__tests__/ProvisioningTimeout.test.tsx
Normal file
186
canvas/src/components/__tests__/ProvisioningTimeout.test.tsx
Normal file
@ -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<WorkspaceData> & { 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<typeof vi.fn>).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);
|
||||
});
|
||||
});
|
||||
84
canvas/src/components/__tests__/TemplatePalette.test.ts
Normal file
84
canvas/src/components/__tests__/TemplatePalette.test.ts
Normal file
@ -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<typeof vi.fn>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
107
canvas/src/components/__tests__/buildTree.test.ts
Normal file
107
canvas/src/components/__tests__/buildTree.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
33
canvas/src/components/canvas/TopBar.tsx
Normal file
33
canvas/src/components/canvas/TopBar.tsx
Normal file
@ -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
|
||||
* <SettingsButton /> into the right cluster.
|
||||
*/
|
||||
export function TopBar({ canvasName = 'Canvas' }: TopBarProps) {
|
||||
return (
|
||||
<div className="top-bar" role="banner">
|
||||
<div className="top-bar__left">
|
||||
<span className="top-bar__logo">☁</span>
|
||||
<span className="top-bar__name">{canvasName}</span>
|
||||
</div>
|
||||
<div className="top-bar__right">
|
||||
<button className="top-bar__btn">+ New Agent</button>
|
||||
{/* === INTEGRATION POINT: Settings gear icon === */}
|
||||
<SettingsButton ref={settingsGearRef} />
|
||||
{/* Bell and Avatar would go here */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
canvas/src/components/settings/AddKeyForm.tsx
Normal file
211
canvas/src/components/settings/AddKeyForm.tsx
Normal file
@ -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<SecretGroup>('github');
|
||||
const [keyName, setKeyName] = useState(getDefaultKeyName('github'));
|
||||
const [value, setValue] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [keyNameError, setKeyNameError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<div className="add-key-form">
|
||||
<div className="add-key-form__header">Add New Key</div>
|
||||
|
||||
{/* Service selector */}
|
||||
<label className="add-key-form__label">
|
||||
Service
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => handleServiceChange(e.target.value as SecretGroup)}
|
||||
disabled={isSaving}
|
||||
className="add-key-form__select"
|
||||
>
|
||||
{SERVICE_GROUP_ORDER.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{SERVICES[group].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Key name */}
|
||||
<label className="add-key-form__label">
|
||||
Key name
|
||||
<input
|
||||
type="text"
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value.toUpperCase())}
|
||||
disabled={isSaving}
|
||||
placeholder="MY_API_KEY"
|
||||
className="add-key-form__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{keyNameError && (
|
||||
<ValidationHint error={keyNameError} />
|
||||
)}
|
||||
|
||||
{/* Key value */}
|
||||
<label className="add-key-form__label">
|
||||
Value
|
||||
</label>
|
||||
<KeyValueField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
disabled={isSaving}
|
||||
aria-label={`Value for ${keyName || 'new key'}`}
|
||||
/>
|
||||
<ValidationHint
|
||||
error={validationError}
|
||||
showValid={!validationError && value.length > 0}
|
||||
/>
|
||||
|
||||
{/* Test connection (only for supported services) */}
|
||||
{service.testSupported && value && !validationError && (
|
||||
<TestConnectionButton
|
||||
provider={selectedGroup}
|
||||
secretValue={value}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save error */}
|
||||
{saveError && (
|
||||
<div className="add-key-form__error" role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="add-key-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="add-key-form__cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="add-key-form__save-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
canvas/src/components/settings/DeleteConfirmDialog.tsx
Normal file
152
canvas/src/components/settings/DeleteConfirmDialog.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [dependents, setDependents] = useState<string[]>([]);
|
||||
const [isLoadingDependents, setIsLoadingDependents] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [confirmEnabled, setConfirmEnabled] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const deleteSecret = useSecretsStore((s) => s.deleteSecret);
|
||||
const confirmTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const abortRef = useRef<AbortController | null>(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<string>).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 (
|
||||
<AlertDialog.Root
|
||||
open={secretName !== null}
|
||||
onOpenChange={(open) => { if (!open) handleCancel(); }}
|
||||
>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="delete-dialog__overlay" />
|
||||
<AlertDialog.Content className="delete-dialog">
|
||||
<AlertDialog.Title className="delete-dialog__title">
|
||||
Delete “{secretName}”?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description className="delete-dialog__desc">
|
||||
This key will be permanently removed.
|
||||
{isLoadingDependents && ' Checking for dependent agents…'}
|
||||
</AlertDialog.Description>
|
||||
|
||||
{!isLoadingDependents && dependents.length > 0 && (
|
||||
<div className="delete-dialog__dependents">
|
||||
<p>Agents that depend on it may stop working:</p>
|
||||
<ul>
|
||||
{dependents.map((d) => (
|
||||
<li key={d}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingDependents && dependents.length === 0 && (
|
||||
<p className="delete-dialog__no-dependents">
|
||||
No agents currently use this key.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="delete-dialog__warning">This cannot be undone.</p>
|
||||
|
||||
{deleteError && (
|
||||
<p className="delete-dialog__error" role="alert">
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="delete-dialog__actions">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button className="delete-dialog__cancel-btn" disabled={isDeleting}>
|
||||
Cancel
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button
|
||||
className="delete-dialog__confirm-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={!confirmEnabled || isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete key'}
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
33
canvas/src/components/settings/EmptyState.tsx
Normal file
33
canvas/src/components/settings/EmptyState.tsx
Normal file
@ -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 (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state__icon" aria-hidden="true">
|
||||
🔑
|
||||
</div>
|
||||
<h3 className="empty-state__title">No API keys yet</h3>
|
||||
<p className="empty-state__body">
|
||||
Add your API keys to let agents connect to GitHub, Anthropic,
|
||||
OpenRouter, and more.
|
||||
</p>
|
||||
<button onClick={onAddFirst} className="empty-state__cta">
|
||||
+ Add your first API key
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
canvas/src/components/settings/SearchBar.tsx
Normal file
57
canvas/src/components/settings/SearchBar.tsx
Normal file
@ -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<HTMLInputElement>(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 (
|
||||
<div className="search-bar">
|
||||
<span className="search-bar__icon" aria-hidden="true">🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search keys…"
|
||||
className="search-bar__input"
|
||||
aria-label="Search API keys"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
canvas/src/components/settings/SecretRow.tsx
Normal file
225
canvas/src/components/settings/SecretRow.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const editBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const revealTimerRef = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<div
|
||||
className={`secret-row ${isEditing ? 'secret-row--editing' : ''}`}
|
||||
role="row"
|
||||
aria-label={`${secret.name} — ${service.label} — ${secret.status}`}
|
||||
>
|
||||
{/* Display mode */}
|
||||
<div className="secret-row__display">
|
||||
<span className="secret-row__name">{secret.name}</span>
|
||||
<span className="secret-row__value">
|
||||
{secret.masked_value}
|
||||
</span>
|
||||
<div className="secret-row__actions">
|
||||
<RevealToggle
|
||||
revealed={revealed}
|
||||
onToggle={() => setRevealed((r) => !r)}
|
||||
label={`Toggle reveal ${secret.name}`}
|
||||
/>
|
||||
<StatusBadge status={secret.status} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={`Copy ${secret.name} to clipboard`}
|
||||
className="secret-row__action-btn"
|
||||
title="Copy"
|
||||
>
|
||||
⎘
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={editBtnRef}
|
||||
onClick={handleEdit}
|
||||
aria-label={`Edit ${secret.name}`}
|
||||
className="secret-row__action-btn"
|
||||
title="Edit"
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete ${secret.name}`}
|
||||
className="secret-row__action-btn secret-row__action-btn--delete"
|
||||
title="Delete"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit mode — inline expand */}
|
||||
{isEditing && (
|
||||
<div className="secret-row__edit-form">
|
||||
<p className="secret-row__edit-hint">
|
||||
Enter new value to replace — current value not shown for security
|
||||
</p>
|
||||
<KeyValueField
|
||||
value={editValue}
|
||||
onChange={setEditValue}
|
||||
disabled={isSaving}
|
||||
aria-label={`New value for ${secret.name}`}
|
||||
/>
|
||||
<ValidationHint
|
||||
error={validationError}
|
||||
showValid={!validationError && editValue.length > 0}
|
||||
/>
|
||||
{service.testSupported && editValue && !validationError && (
|
||||
<TestConnectionButton
|
||||
provider={secret.group}
|
||||
secretValue={editValue}
|
||||
onResult={(valid) =>
|
||||
setSecretStatus(secret.name, valid ? 'verified' : 'invalid')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{saveError && (
|
||||
<p className="secret-row__save-error" role="alert">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
<div className="secret-row__edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="secret-row__cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !editValue || !!validationError}
|
||||
className="secret-row__save-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
canvas/src/components/settings/SecretsTab.tsx
Normal file
144
canvas/src/components/settings/SecretsTab.tsx
Normal file
@ -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<SecretGroup, Secret[]>;
|
||||
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 (
|
||||
<div className="secrets-tab__error" role="alert">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => fetchSecrets(workspaceId)}
|
||||
className="secrets-tab__refresh-btn"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="secrets-tab__loading" aria-busy="true">
|
||||
Loading API keys…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (secrets.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<EmptyState onAddFirst={() => setAddFormOpen(true)} />
|
||||
{isAddFormOpen && (
|
||||
<AddKeyForm
|
||||
workspaceId={workspaceId}
|
||||
existingNames={[]}
|
||||
onCancel={() => setAddFormOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if search filtered everything out
|
||||
const totalFiltered = Object.values(grouped).reduce(
|
||||
(sum, arr) => sum + arr.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="secrets-tab">
|
||||
{showSearch && <SearchBar />}
|
||||
|
||||
{totalFiltered === 0 && searchQuery && (
|
||||
<div className="secrets-tab__no-results">
|
||||
No keys match “{searchQuery}”
|
||||
<button
|
||||
onClick={() => useSecretsStore.getState().setSearchQuery('')}
|
||||
className="secrets-tab__clear-search"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SERVICE_GROUP_ORDER.map((groupKey) => {
|
||||
const groupSecrets = grouped[groupKey];
|
||||
if (groupSecrets.length === 0) return null;
|
||||
return (
|
||||
<ServiceGroup
|
||||
key={groupKey}
|
||||
group={groupKey}
|
||||
service={SERVICES[groupKey]}
|
||||
secrets={groupSecrets}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="secrets-tab__add-section">
|
||||
{isAddFormOpen ? (
|
||||
<AddKeyForm
|
||||
workspaceId={workspaceId}
|
||||
existingNames={secrets.map((s) => s.name)}
|
||||
onCancel={() => setAddFormOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAddFormOpen(true)}
|
||||
className="secrets-tab__add-btn"
|
||||
>
|
||||
+ Add API Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
canvas/src/components/settings/ServiceGroup.tsx
Normal file
60
canvas/src/components/settings/ServiceGroup.tsx
Normal file
@ -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 (
|
||||
<div className="service-group" role="group" aria-label={`${service.label} keys`}>
|
||||
<div className="service-group__header">
|
||||
<ServiceIcon name={service.icon} />
|
||||
<span className="service-group__label">{service.label}</span>
|
||||
<span className="service-group__count">{countLabel}</span>
|
||||
</div>
|
||||
<div className="service-group__rows">
|
||||
{secrets.map((secret) => (
|
||||
<SecretRow
|
||||
key={secret.name}
|
||||
secret={secret}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceIcon({ name }: { name: string }) {
|
||||
// Placeholder — real implementation would use SVG imports or an icon component
|
||||
const icons: Record<string, string> = {
|
||||
github: '🐙',
|
||||
anthropic: '🤖',
|
||||
openrouter: '🔀',
|
||||
key: '🔑',
|
||||
};
|
||||
return (
|
||||
<span className="service-group__icon" aria-hidden="true">
|
||||
{icons[name] ?? '🔑'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
70
canvas/src/components/settings/SettingsButton.tsx
Normal file
70
canvas/src/components/settings/SettingsButton.tsx
Normal file
@ -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<HTMLButtonElement>(
|
||||
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 (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`settings-button ${isPanelOpen ? 'settings-button--active' : ''}`}
|
||||
aria-label="Settings"
|
||||
aria-expanded={isPanelOpen}
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="settings-button__tooltip" sideOffset={5}>
|
||||
Settings {isMac ? '⌘,' : 'Ctrl+,'}
|
||||
<Tooltip.Arrow />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
155
canvas/src/components/settings/SettingsPanel.tsx
Normal file
155
canvas/src/components/settings/SettingsPanel.tsx
Normal file
@ -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<HTMLButtonElement>();
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Dialog.Root open={isPanelOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="settings-panel__backdrop" />
|
||||
<Dialog.Content
|
||||
className="settings-panel"
|
||||
aria-label="Settings: API Keys"
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (hasDirtyForm) {
|
||||
e.preventDefault();
|
||||
setShowGuard(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="settings-panel__header">
|
||||
<Dialog.Title className="settings-panel__title">
|
||||
Settings
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="settings-panel__close"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<Tabs.Root defaultValue="api-keys">
|
||||
<Tabs.List className="settings-panel__tabs" aria-label="Settings sections">
|
||||
<Tabs.Trigger value="api-keys" className="settings-panel__tab">
|
||||
API Keys
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="general"
|
||||
className="settings-panel__tab"
|
||||
disabled
|
||||
>
|
||||
General
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="api-keys" className="settings-panel__content">
|
||||
<SecretsTab workspaceId={workspaceId} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="general" className="settings-panel__content">
|
||||
{/* Future: General settings */}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<div className="settings-panel__footer">
|
||||
<span className="settings-panel__shortcut-hint">
|
||||
{isMac ? '⌘,' : 'Ctrl+,'}
|
||||
</span>
|
||||
<span className="settings-panel__separator">·</span>
|
||||
<a
|
||||
href="https://docs.example.com/secrets"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-panel__docs-link"
|
||||
>
|
||||
Learn about secrets →
|
||||
</a>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<UnsavedChangesGuard
|
||||
open={showGuard}
|
||||
onKeepEditing={() => setShowGuard(false)}
|
||||
onDiscard={confirmDiscard}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
48
canvas/src/components/settings/UnsavedChangesGuard.tsx
Normal file
48
canvas/src/components/settings/UnsavedChangesGuard.tsx
Normal file
@ -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 (
|
||||
<AlertDialog.Root open={open} onOpenChange={(o) => { if (!o) onKeepEditing(); }}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="guard-dialog__overlay" />
|
||||
<AlertDialog.Content className="guard-dialog">
|
||||
<AlertDialog.Title className="guard-dialog__title">
|
||||
Discard unsaved changes?
|
||||
</AlertDialog.Title>
|
||||
<div className="guard-dialog__actions">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button className="guard-dialog__keep-btn" onClick={onKeepEditing}>
|
||||
Keep editing
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button className="guard-dialog__discard-btn" onClick={onDiscard}>
|
||||
Discard
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
||||
10
canvas/src/components/settings/index.ts
Normal file
10
canvas/src/components/settings/index.ts
Normal file
@ -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';
|
||||
392
canvas/src/components/tabs/ActivityTab.tsx
Normal file
392
canvas/src/components/tabs/ActivityTab.tsx
Normal file
@ -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<string, { text: string; bg: string; border: string }> = {
|
||||
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<string, { icon: string; color: string }> = {
|
||||
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<ActivityEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [expanded, setExpanded] = useState<string | null>(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<ActivityEntry[]>(`/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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Filter bar */}
|
||||
<div className="px-3 pt-3 pb-2 border-b border-zinc-800/40">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-2 py-1 text-[9px] rounded-md font-medium transition-all ${
|
||||
filter === f.id
|
||||
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded ${
|
||||
autoRefresh ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500"
|
||||
}`}
|
||||
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
||||
>
|
||||
{autoRefresh ? "⟳ Live" : "⟳ Paused"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTraceOpen(true)}
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[9px] rounded text-blue-300 border border-blue-800/30"
|
||||
title="View full conversation trace across all workspaces"
|
||||
>
|
||||
Full Trace
|
||||
</button>
|
||||
<button
|
||||
onClick={loadActivities}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[9px] rounded text-zinc-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-zinc-500">
|
||||
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||
{loading && activities.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">Loading activity...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && activities.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-zinc-600 text-xs">No activity recorded yet</div>
|
||||
<div className="text-zinc-700 text-[9px] mt-1">
|
||||
Activity logs appear when agents communicate or perform tasks
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activities.map((entry) => (
|
||||
<ActivityRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
expanded={expanded === entry.id}
|
||||
onToggle={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||
resolveName={resolveName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConversationTraceModal
|
||||
open={traceOpen}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => setTraceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isError
|
||||
? "bg-red-950/20 border-red-900/30"
|
||||
: "bg-zinc-800/60 border-zinc-700/40"
|
||||
}`}
|
||||
>
|
||||
<button onClick={onToggle} className="w-full text-left px-3 py-2">
|
||||
{/* Top row: type badge + method + time */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[8px] font-mono px-1.5 py-0.5 rounded ${typeStyle.text} ${typeStyle.bg} border ${typeStyle.border}`}>
|
||||
{formatType(entry.activity_type)}
|
||||
</span>
|
||||
|
||||
{entry.method && (
|
||||
<span className="text-[10px] font-mono text-zinc-300 truncate">
|
||||
{entry.method}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-[9px] ml-auto shrink-0 ${statusStyle.color}`}>
|
||||
{statusStyle.icon}
|
||||
</span>
|
||||
|
||||
{entry.duration_ms != null && (
|
||||
<span className="text-[8px] text-zinc-500 font-mono tabular-nums shrink-0">
|
||||
{entry.duration_ms}ms
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[8px] text-zinc-600 shrink-0">
|
||||
{formatTime(entry.created_at)}
|
||||
</span>
|
||||
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary — replace raw IDs with workspace names */}
|
||||
{entry.summary && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1 truncate">
|
||||
{entry.summary
|
||||
.replace(entry.source_id || "", resolveName(entry.source_id))
|
||||
.replace(entry.target_id || "", resolveName(entry.target_id))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A2A flow indicator */}
|
||||
{isA2A && (entry.source_id || entry.target_id) && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{entry.source_id && (
|
||||
<span className="text-[9px] text-cyan-400/80 truncate max-w-[140px]" title={entry.source_id}>
|
||||
{resolveName(entry.source_id)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-zinc-600">→</span>
|
||||
{entry.target_id && (
|
||||
<span className="text-[9px] text-blue-400/80 truncate max-w-[140px]" title={entry.target_id}>
|
||||
{resolveName(entry.target_id)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[9px] text-red-400/80 mt-1 truncate">
|
||||
{entry.error_detail}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-zinc-700/30 mt-1 pt-2">
|
||||
{entry.source_id && (
|
||||
<Detail label="Source" value={`${resolveName(entry.source_id)} (${entry.source_id.slice(0, 8)})`} />
|
||||
)}
|
||||
{entry.target_id && (
|
||||
<Detail label="Target" value={`${resolveName(entry.target_id)} (${entry.target_id.slice(0, 8)})`} />
|
||||
)}
|
||||
{/* Message preview — extract text from A2A request/response */}
|
||||
{entry.request_body && (
|
||||
<MessagePreview label="Message Sent" body={entry.request_body} />
|
||||
)}
|
||||
{entry.response_body && (
|
||||
<MessagePreview label="Response" body={entry.response_body} />
|
||||
)}
|
||||
{entry.error_detail && (
|
||||
<Detail label="Error" value={entry.error_detail} error />
|
||||
)}
|
||||
{entry.request_body && (
|
||||
<JsonBlock label="Raw Request" data={entry.request_body} />
|
||||
)}
|
||||
{entry.response_body && (
|
||||
<JsonBlock label="Response" data={entry.response_body} />
|
||||
)}
|
||||
<div className="text-[8px] text-zinc-600 font-mono select-all">
|
||||
ID: {entry.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract human-readable text from A2A request/response JSON */
|
||||
function MessagePreview({ label, body }: { label: string; body: Record<string, unknown> }) {
|
||||
// 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 (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{text.slice(0, 2000)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Request: params.message.parts[].text
|
||||
const params = body.params as Record<string, unknown> | undefined;
|
||||
const message = params?.message as Record<string, unknown> | undefined;
|
||||
const parts = (message?.parts || []) as Array<Record<string, unknown>>;
|
||||
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<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
text = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | 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 (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{text.slice(0, 2000)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[8px] text-zinc-500 uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
||||
<span className={`text-[9px] break-all ${isError ? "text-red-400" : "text-zinc-300"} ${mono ? "font-mono" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<pre className="text-[9px] text-zinc-300 bg-zinc-900/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
359
canvas/src/components/tabs/ChannelsTab.tsx
Normal file
359
canvas/src/components/tabs/ChannelsTab.tsx
Normal file
@ -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<string, string>;
|
||||
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<Channel[]>([]);
|
||||
const [adapters, setAdapters] = useState<ChannelAdapter[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(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<Set<string>>(new Set());
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [chRes, adRes] = await Promise.all([
|
||||
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/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 (
|
||||
<div className="p-4 text-zinc-500 text-xs">Loading channels...</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-zinc-300 tracking-wide uppercase">
|
||||
Channels
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="text-[10px] px-2.5 py-1 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition"
|
||||
>
|
||||
{showForm ? "Cancel" : "+ Connect"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Platform</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value)}
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300"
|
||||
>
|
||||
{adapters.map((a) => (
|
||||
<option key={a.type} value={a.type}>{a.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formBotToken}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formBotToken}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
|
||||
>
|
||||
{discovering ? "Detecting..." : "Detect Chats"}
|
||||
</button>
|
||||
</div>
|
||||
{discoveredChats.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{discoveredChats.map((chat) => (
|
||||
<label
|
||||
key={chat.chat_id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChats.has(chat.chat_id)}
|
||||
onChange={() => toggleChat(chat.chat_id)}
|
||||
className="rounded border-zinc-600"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(discoveredChats.length === 0 || showManualInput) && (
|
||||
<input
|
||||
value={formChatId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[9px] text-zinc-600 mt-0.5">
|
||||
{discoveredChats.length > 0 ? (
|
||||
<>
|
||||
Chats: <span className="text-zinc-400">{formChatId || "(none selected)"}</span>
|
||||
{" · "}
|
||||
<button
|
||||
onClick={() => setShowManualInput(!showManualInput)}
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{showManualInput ? "hide manual input" : "edit manually"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
"Click Detect Chats after adding the bot to groups or sending /start in DMs."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
|
||||
</label>
|
||||
<input
|
||||
value={formAllowedUsers}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-[9px] text-zinc-600 mt-0.5">
|
||||
Telegram user IDs. Leave empty to allow everyone.
|
||||
</p>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="text-[10px] text-red-400">{formError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="w-full text-xs py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-white transition"
|
||||
>
|
||||
Connect Channel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
{channels.length === 0 && !showForm && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-zinc-500 text-xs">No channels connected</p>
|
||||
<p className="text-zinc-600 text-[10px] mt-1">
|
||||
Connect Telegram, Slack, or Discord to chat with this agent from social platforms.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="p-3 bg-zinc-800/30 rounded border border-zinc-700/40 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
ch.enabled ? "bg-emerald-500" : "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-medium text-zinc-200">
|
||||
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{ch.config.chat_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleTest(ch)}
|
||||
disabled={testing === ch.id}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-zinc-700/50 text-zinc-400 hover:text-zinc-200 transition disabled:opacity-50"
|
||||
>
|
||||
{testing === ch.id ? "Sent!" : "Test"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(ch)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition ${
|
||||
ch.enabled
|
||||
? "bg-emerald-900/30 text-emerald-400 hover:bg-emerald-900/50"
|
||||
: "bg-zinc-700/50 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{ch.enabled ? "On" : "Off"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(ch)}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-red-400 hover:bg-red-900/40 transition"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[10px] text-zinc-500">
|
||||
<span>{ch.message_count} messages</span>
|
||||
<span>Last: {relativeTime(ch.last_message_at)}</span>
|
||||
{ch.allowed_users.length > 0 && (
|
||||
<span>{ch.allowed_users.length} allowed user(s)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
437
canvas/src/components/tabs/ChatTab.tsx
Normal file
437
canvas/src/components/tabs/ChatTab.tsx
Normal file
@ -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<ChatMessage[]> {
|
||||
try {
|
||||
const activities = await api.get<Array<{
|
||||
activity_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
request_body: Record<string, unknown> | null;
|
||||
response_body: Record<string, unknown> | 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<ChatSubTab>("my-chat");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setSubTab("my-chat")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "my-chat"
|
||||
? "text-zinc-200 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
My Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab("agent-comms")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "agent-comms"
|
||||
? "text-zinc-200 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
Agent Comms
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{subTab === "my-chat" ? (
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
) : (
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
|
||||
*/
|
||||
function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(!!data.currentTask);
|
||||
const [thinkingElapsed, setThinkingElapsed] = useState(0);
|
||||
const [activityLog, setActivityLog] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const currentTaskRef = useRef(data.currentTask);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const [agentReachable, setAgentReachable] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(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<A2AResponse>(`/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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-4">Loading chat history...</div>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No messages yet. Send a message to start chatting with this agent.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.role === "user"
|
||||
? "bg-blue-600/30 text-blue-100 border border-blue-500/20"
|
||||
: msg.role === "system"
|
||||
? "bg-red-900/30 text-red-200 border border-red-800/30"
|
||||
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="text-[9px] text-zinc-500 mt-1">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{sending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-zinc-800/50 border border-zinc-700/30 rounded-lg px-3 py-2 max-w-[85%]">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span className="flex gap-0.5">
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
{thinkingElapsed}s
|
||||
</div>
|
||||
{activityLog.length > 0 && (
|
||||
<div className="mt-1.5 text-[9px] text-zinc-500 space-y-0.5">
|
||||
<div className="text-zinc-400">Processing with {runtimeDisplayName(data.runtime)}...</div>
|
||||
{activityLog.map((line, i) => (
|
||||
<div key={i} className="pl-2 border-l border-zinc-700">◇ {line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-red-400">{error}</span>
|
||||
{!isOnline && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Restart this workspace?")) {
|
||||
useCanvasStore.getState().restartWorkspace(workspaceId);
|
||||
}
|
||||
}}
|
||||
className="text-[9px] px-2 py-0.5 bg-red-800/40 text-red-300 rounded hover:bg-red-700/50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-zinc-800">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={agentReachable ? "Send a message... (Shift+Enter for new line)" : `Agent is ${data.status}`}
|
||||
disabled={!agentReachable || sending}
|
||||
rows={1}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-blue-500 resize-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || !agentReachable || sending}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
canvas/src/components/tabs/ConfigTab.tsx
Normal file
348
canvas/src/components/tabs/ConfigTab.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
|
||||
import { parseYaml, toYaml } from "./config/yaml-utils";
|
||||
import { SecretsSection } from "./config/secrets-section";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// --- Agent Card Section ---
|
||||
|
||||
function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
const [card, setCard] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Record<string, unknown>>(`/workspaces/${workspaceId}`)
|
||||
.then((ws) => setCard((ws.agent_card as Record<string, unknown>) || null))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [workspaceId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(draft); } catch { setError("Invalid JSON"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post("/registry/update-card", { workspace_id: workspaceId, agent_card: parsed });
|
||||
setCard(parsed as Record<string, unknown>);
|
||||
setSuccess(true);
|
||||
setEditing(false);
|
||||
setTimeout(() => setSuccess(false), 2000);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "Failed to update"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Agent Card" defaultOpen={false}>
|
||||
{loading ? (
|
||||
<div className="text-[10px] text-zinc-500">Loading...</div>
|
||||
) : editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
spellCheck={false} rows={12}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-[10px] font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{card ? (
|
||||
<pre className="text-[9px] text-zinc-400 bg-zinc-800/50 rounded p-2 overflow-x-auto max-h-48 border border-zinc-700/50">
|
||||
{JSON.stringify(card, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[10px] text-zinc-500">No agent card</div>
|
||||
)}
|
||||
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-green-400">Updated</div>}
|
||||
<button onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
|
||||
className="mt-2 text-[10px] text-blue-400 hover:text-blue-300">Edit Agent Card</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ConfigTab ---
|
||||
|
||||
export function ConfigTab({ workspaceId }: Props) {
|
||||
const [config, setConfig] = useState<ConfigData>({ ...DEFAULT_CONFIG });
|
||||
const [originalYaml, setOriginalYaml] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
const [rawDraft, setRawDraft] = useState("");
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(successTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/config.yaml`);
|
||||
const parsed = parseYaml(res.content);
|
||||
setOriginalYaml(res.content);
|
||||
setRawDraft(res.content);
|
||||
setConfig({ ...DEFAULT_CONFIG, ...parsed } as ConfigData);
|
||||
} catch {
|
||||
setError("No config.yaml found");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const update = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateNested = <K extends keyof ConfigData>(key: K, subKey: string, value: unknown) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...(prev[key] as Record<string, unknown>), [subKey]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (restart: boolean) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
const content = rawMode ? rawDraft : toYaml(config);
|
||||
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
|
||||
|
||||
// If runtime changed, update it in the DB so restart uses the correct image
|
||||
const newRuntime = rawMode
|
||||
? (parseYaml(rawDraft).runtime as string || "")
|
||||
: (config.runtime || "");
|
||||
const oldRuntime = (parseYaml(originalYaml).runtime as string || "");
|
||||
if (newRuntime && newRuntime !== oldRuntime) {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { runtime: newRuntime });
|
||||
}
|
||||
|
||||
setOriginalYaml(content);
|
||||
if (rawMode) {
|
||||
const parsed = parseYaml(content);
|
||||
setConfig({ ...DEFAULT_CONFIG, ...parsed } as ConfigData);
|
||||
} else {
|
||||
setRawDraft(content);
|
||||
}
|
||||
if (restart) {
|
||||
await useCanvasStore.getState().restartWorkspace(workspaceId);
|
||||
} else {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
}
|
||||
setSuccess(true);
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = setTimeout(() => setSuccess(false), 2000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml;
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-zinc-500">Loading config...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/30">
|
||||
<span className="text-[10px] text-zinc-500">config.yaml</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<span className="text-[9px] text-zinc-500">Raw YAML</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rawMode}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setRawDraft(toYaml(config));
|
||||
} else {
|
||||
const parsed = parseYaml(rawDraft);
|
||||
setConfig({ ...DEFAULT_CONFIG, ...parsed } as ConfigData);
|
||||
}
|
||||
setRawMode(e.target.checked);
|
||||
}}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{rawMode ? (
|
||||
<div className="flex-1 p-3">
|
||||
<textarea
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full h-full min-h-[300px] bg-zinc-800 border border-zinc-700 rounded p-3 text-xs font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
<Section title="General">
|
||||
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Description</label>
|
||||
<textarea
|
||||
value={config.description}
|
||||
onChange={(e) => update("description", e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Tier</label>
|
||||
<select
|
||||
value={config.tier}
|
||||
onChange={(e) => update("tier", parseInt(e.target.value, 10))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value={1}>T1 — Sandboxed</option>
|
||||
<option value={2}>T2 — Standard</option>
|
||||
<option value={3}>T3 — Full Access</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Runtime">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<select
|
||||
value={config.runtime || ""}
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">LangGraph (default)</option>
|
||||
<option value="claude-code">Claude Code</option>
|
||||
<option value="crewai">CrewAI</option>
|
||||
<option value="autogen">AutoGen</option>
|
||||
<option value="deepagents">DeepAgents</option>
|
||||
<option value="openclaw">OpenClaw</option>
|
||||
</select>
|
||||
</div>
|
||||
<TextInput label="Model" value={config.runtime_config?.model || config.model || ""} onChange={(v) => {
|
||||
if (config.runtime) {
|
||||
update("runtime_config", { ...config.runtime_config, model: v });
|
||||
} else {
|
||||
update("model", v);
|
||||
}
|
||||
}} placeholder="e.g. anthropic:claude-sonnet-4-6" mono />
|
||||
</div>
|
||||
<TagList label="Required Env Vars" values={config.runtime_config?.required_env || []} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" />
|
||||
</Section>
|
||||
|
||||
<Section title="Skills & Tools" defaultOpen={false}>
|
||||
<TagList label="Skills" values={config.skills || []} onChange={(v) => update("skills", v)} placeholder="e.g. code-review" />
|
||||
<TagList label="Tools" values={config.tools || []} onChange={(v) => update("tools", v)} placeholder="e.g. web_search, filesystem" />
|
||||
<TagList label="Prompt Files" values={config.prompt_files || []} onChange={(v) => update("prompt_files", v)} placeholder="e.g. system-prompt.md" />
|
||||
<TagList label="Shared Context" values={config.shared_context || []} onChange={(v) => update("shared_context", v)} placeholder="e.g. architecture.md" />
|
||||
</Section>
|
||||
|
||||
<Section title="A2A Protocol" defaultOpen={false}>
|
||||
<NumberInput label="Port" value={config.a2a?.port ?? 8000} onChange={(v) => updateNested("a2a" as keyof ConfigData, "port", v)} />
|
||||
<Toggle label="Streaming" checked={config.a2a?.streaming ?? true} onChange={(v) => updateNested("a2a" as keyof ConfigData, "streaming", v)} />
|
||||
<Toggle label="Push Notifications" checked={config.a2a?.push_notifications ?? true} onChange={(v) => updateNested("a2a" as keyof ConfigData, "push_notifications", v)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Delegation" defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberInput label="Retry Attempts" value={config.delegation?.retry_attempts ?? 3} onChange={(v) => updateNested("delegation" as keyof ConfigData, "retry_attempts", v)} min={0} max={10} />
|
||||
<NumberInput label="Retry Delay (s)" value={config.delegation?.retry_delay ?? 5} onChange={(v) => updateNested("delegation" as keyof ConfigData, "retry_delay", v)} min={1} />
|
||||
</div>
|
||||
<NumberInput label="Timeout (s)" value={config.delegation?.timeout ?? 120} onChange={(v) => updateNested("delegation" as keyof ConfigData, "timeout", v)} min={10} />
|
||||
<Toggle label="Escalate on failure" checked={config.delegation?.escalate ?? true} onChange={(v) => updateNested("delegation" as keyof ConfigData, "escalate", v)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Sandbox" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Backend</label>
|
||||
<select
|
||||
value={config.sandbox?.backend || "docker"}
|
||||
onChange={(e) => updateNested("sandbox" as keyof ConfigData, "backend", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="subprocess">subprocess</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="e2b">e2b</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput label="Memory Limit" value={config.sandbox?.memory_limit || "256m"} onChange={(v) => updateNested("sandbox" as keyof ConfigData, "memory_limit", v)} mono />
|
||||
<NumberInput label="Timeout (s)" value={config.sandbox?.timeout ?? 30} onChange={(v) => updateNested("sandbox" as keyof ConfigData, "timeout", v)} min={5} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<SecretsSection workspaceId={workspaceId} />
|
||||
|
||||
<AgentCardSection workspaceId={workspaceId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-green-900/30 border border-green-800 rounded text-xs text-green-400">Saved</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 border-t border-zinc-800 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-30 transition-colors"
|
||||
>
|
||||
{saving ? "Restarting..." : "Save & Restart"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 ml-auto"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
canvas/src/components/tabs/DetailsTab.tsx
Normal file
304
canvas/src/components/tabs/DetailsTab.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { StatusDot } from "../StatusDot";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
data: WorkspaceNodeData;
|
||||
}
|
||||
|
||||
interface PeerData {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
status: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(data.name);
|
||||
const [role, setRole] = useState(data.role || "");
|
||||
const [tier, setTier] = useState(data.tier);
|
||||
const [peers, setPeers] = useState<PeerData[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [peersError, setPeersError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const updateNodeData = useCanvasStore((s) => s.updateNodeData);
|
||||
const removeNode = useCanvasStore((s) => s.removeNode);
|
||||
const selectNode = useCanvasStore((s) => s.selectNode);
|
||||
|
||||
useEffect(() => {
|
||||
setName(data.name);
|
||||
setRole(data.role || "");
|
||||
setTier(data.tier);
|
||||
}, [data.name, data.role, data.tier]);
|
||||
|
||||
const loadPeers = useCallback(async () => {
|
||||
setPeersError(null);
|
||||
try {
|
||||
const peerList = await api.get<PeerData[]>(`/registry/${workspaceId}/peers`);
|
||||
setPeers(peerList);
|
||||
} catch (e) {
|
||||
setPeersError(e instanceof Error ? e.message : "Failed to load peers");
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPeers();
|
||||
}, [loadPeers]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier });
|
||||
updateNodeData(workspaceId, { name, role: role || "", tier });
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}?confirm=true`);
|
||||
removeNode(workspaceId);
|
||||
selectNode(null);
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : "Failed to delete");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true);
|
||||
setRestartError(null);
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/restart`, {});
|
||||
updateNodeData(workspaceId, { status: "provisioning" });
|
||||
} catch (e) {
|
||||
setRestartError(e instanceof Error ? e.message : "Failed to restart");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isRestartable = data.status === "offline" || data.status === "failed" || data.status === "degraded";
|
||||
|
||||
const agentCard = data.agentCard;
|
||||
const skills = getSkills(agentCard);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Editable fields */}
|
||||
<Section title="Workspace">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<input
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="e.g. SEO Specialist"
|
||||
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tier">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(Number(e.target.value))}
|
||||
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value={1}>Tier 1 — No privileges</option>
|
||||
<option value={2}>Tier 2 — Browser</option>
|
||||
<option value={3}>Tier 3 — Desktop</option>
|
||||
<option value={4}>Tier 4 — VM</option>
|
||||
</select>
|
||||
</Field>
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setSaveError(null); setName(data.name); setRole(data.role || ""); setTier(data.tier); }}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Row label="Name" value={data.name} />
|
||||
<Row label="Role" value={data.role || "—"} />
|
||||
<Row label="Tier" value={`T${data.tier}`} />
|
||||
<Row label="Status" value={data.status} />
|
||||
<Row label="URL" value={data.url || "—"} mono />
|
||||
<Row label="Parent" value={data.parentId || "root"} mono />
|
||||
<Row label="Active Tasks" value={String(data.activeTasks)} />
|
||||
{data.status === "degraded" && (
|
||||
<Row label="Error Rate" value={`${(data.lastErrorRate * 100).toFixed(0)}%`} />
|
||||
)}
|
||||
{isRestartable && (
|
||||
<div className="pt-2">
|
||||
{restartError && (
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{restartError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50"
|
||||
>
|
||||
{restarting ? "Restarting..." : data.status === "failed" ? "Retry" : "Restart"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-2 px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Agent Card / Skills */}
|
||||
{skills.length > 0 && (
|
||||
<Section title="Skills">
|
||||
<div className="space-y-1">
|
||||
{skills.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-2">
|
||||
<span className="text-xs text-blue-400 font-mono shrink-0">{s.id}</span>
|
||||
{s.description && (
|
||||
<span className="text-xs text-zinc-500">{s.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Peers */}
|
||||
<Section title={`Peers (${peers.length})`}>
|
||||
{peersError ? (
|
||||
<p className="text-xs text-red-400">{peersError}</p>
|
||||
) : peers.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">No reachable peers</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{peers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => selectNode(p.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-zinc-800 text-left"
|
||||
>
|
||||
<StatusDot status={p.status} />
|
||||
<span className="text-xs text-zinc-200">{p.name}</span>
|
||||
{p.role && <span className="text-[10px] text-zinc-500">{p.role}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Delete */}
|
||||
<Section title="Danger Zone">
|
||||
{deleteError && (
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
{confirmDelete ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirmDelete(false); setDeleteError(null); }}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="px-3 py-1 bg-zinc-800 hover:bg-red-900 border border-zinc-700 hover:border-red-700 text-xs rounded text-zinc-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Delete Workspace
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className={`text-xs text-zinc-200 ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
|
||||
if (!card) return [];
|
||||
const skills = card.skills;
|
||||
if (!Array.isArray(skills)) return [];
|
||||
return skills.map((s: Record<string, unknown>) => ({
|
||||
id: String(s.id || s.name || ""),
|
||||
description: s.description ? String(s.description) : undefined,
|
||||
})).filter((s) => s.id);
|
||||
}
|
||||
132
canvas/src/components/tabs/EventsTab.tsx
Normal file
132
canvas/src/components/tabs/EventsTab.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface EventEntry {
|
||||
id: string;
|
||||
event_type: string;
|
||||
workspace_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
WORKSPACE_ONLINE: "text-green-400",
|
||||
WORKSPACE_OFFLINE: "text-zinc-400",
|
||||
WORKSPACE_DEGRADED: "text-yellow-400",
|
||||
WORKSPACE_PROVISIONING: "text-blue-400",
|
||||
WORKSPACE_REMOVED: "text-red-400",
|
||||
WORKSPACE_PROVISION_FAILED: "text-red-400",
|
||||
AGENT_CARD_UPDATED: "text-purple-400",
|
||||
};
|
||||
|
||||
export function EventsTab({ workspaceId }: Props) {
|
||||
const [events, setEvents] = useState<EventEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<EventEntry[]>(`/events/${workspaceId}`);
|
||||
setEvents(data);
|
||||
} catch (e) {
|
||||
setEvents([]);
|
||||
setError(e instanceof Error ? e.message : "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// Auto-refresh every 10s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadEvents, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadEvents]);
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return <div className="p-4 text-xs text-zinc-500">Loading events...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-400">{events.length} events</span>
|
||||
<button
|
||||
onClick={loadEvents}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && events.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500 text-center py-4">No events yet</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="bg-zinc-800 rounded border border-zinc-700">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === event.id ? null : event.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
EVENT_COLORS[event.event_type] || "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{event.event_type}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-500 ml-auto">
|
||||
{formatTime(event.created_at)}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{expanded === event.id ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded === event.id && (
|
||||
<div className="px-3 pb-2">
|
||||
<pre className="text-[10px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-1 text-[9px] text-zinc-500 font-mono">
|
||||
ID: {event.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ago`;
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
650
canvas/src/components/tabs/FilesTab.tsx
Normal file
650
canvas/src/components/tabs/FilesTab.tsx
Normal file
@ -0,0 +1,650 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { showToast } from "../Toaster";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
dir: boolean;
|
||||
}
|
||||
|
||||
const FILE_ICONS: Record<string, string> = {
|
||||
".md": "📄",
|
||||
".yaml": "⚙",
|
||||
".yml": "⚙",
|
||||
".py": "🐍",
|
||||
".ts": "💠",
|
||||
".tsx": "💠",
|
||||
".js": "📜",
|
||||
".json": "{}",
|
||||
".html": "🌐",
|
||||
".css": "🎨",
|
||||
".sh": "▸",
|
||||
};
|
||||
|
||||
function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + path.split(".").pop();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
export function FilesTab({ workspaceId }: Props) {
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [loadingFile, setLoadingFile] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showNewFile, setShowNewFile] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(successTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [loadingDir, setLoadingDir] = useState<string | null>(null);
|
||||
const expandedDirsRef = useRef(expandedDirs);
|
||||
expandedDirsRef.current = expandedDirs;
|
||||
|
||||
const loadFiles = useCallback(async (subPath = "", depth = 1) => {
|
||||
if (!subPath) setLoading(true);
|
||||
else setLoadingDir(subPath);
|
||||
try {
|
||||
const params = new URLSearchParams({ root, depth: String(depth) });
|
||||
if (subPath) params.set("path", subPath);
|
||||
const data = await api.get<FileEntry[]>(`/workspaces/${workspaceId}/files?${params}`);
|
||||
if (!subPath) {
|
||||
// Root load — replace all
|
||||
setFiles(data);
|
||||
} else {
|
||||
// Subfolder load — merge direct children only (preserve expanded grandchildren)
|
||||
setFiles((prev) => {
|
||||
const prefix = subPath + "/";
|
||||
// Remove only direct children of this subPath (not deeper descendants)
|
||||
const filtered = prev.filter((f) => {
|
||||
if (!f.path.startsWith(prefix)) return true;
|
||||
const remainder = f.path.slice(prefix.length);
|
||||
// Keep entries that are nested deeper (grandchildren of other expanded dirs)
|
||||
return remainder.includes("/");
|
||||
});
|
||||
const newFiles = data.map((f) => ({ ...f, path: subPath + "/" + f.path }));
|
||||
return [...filtered, ...newFiles];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!subPath) setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingDir(null);
|
||||
}
|
||||
}, [workspaceId, root]);
|
||||
|
||||
const toggleDir = useCallback((dirPath: string) => {
|
||||
const wasExpanded = expandedDirsRef.current.has(dirPath);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dirPath)) {
|
||||
next.delete(dirPath);
|
||||
} else {
|
||||
next.add(dirPath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!wasExpanded) {
|
||||
loadFiles(dirPath, 1);
|
||||
}
|
||||
}, [loadFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDirs(new Set());
|
||||
loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
setLoadingFile(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`);
|
||||
setSelectedFile(path);
|
||||
setFileContent(res.content);
|
||||
setEditContent(res.content);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to read file");
|
||||
} finally {
|
||||
setLoadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!selectedFile) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/workspaces/${workspaceId}/files/${selectedFile}`, { content: editContent });
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
setFileContent(editContent);
|
||||
setSuccess("Saved");
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = setTimeout(() => setSuccess(null), 2000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteFile = (path: string) => {
|
||||
setConfirmDelete(path);
|
||||
};
|
||||
|
||||
const confirmDeleteFile = async () => {
|
||||
if (!confirmDelete) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/files/${confirmDelete}`);
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
if (selectedFile === confirmDelete) {
|
||||
setSelectedFile(null);
|
||||
setFileContent("");
|
||||
setEditContent("");
|
||||
}
|
||||
loadFiles();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete");
|
||||
} finally {
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const createFile = async () => {
|
||||
if (!newFileName.trim()) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/workspaces/${workspaceId}/files/${newFileName.trim()}`, { content: "" });
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
setShowNewFile(false);
|
||||
setNewFileName("");
|
||||
loadFiles();
|
||||
openFile(newFileName.trim());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create");
|
||||
}
|
||||
};
|
||||
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDownloadFile = () => {
|
||||
if (!selectedFile || !fileContent) return;
|
||||
const blob = new Blob([editContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = selectedFile.split("/").pop() || "file";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Downloaded", "success");
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const fileEntries = files.filter((f) => !f.dir);
|
||||
const results = await Promise.allSettled(
|
||||
fileEntries.map((f) => api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${f.path}`).then((res) => ({ path: f.path, content: res.content })))
|
||||
);
|
||||
const allFiles: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled") allFiles[r.value.path] = r.value.content;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "workspace-files.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Downloaded ${Object.keys(allFiles).length} files`, "success");
|
||||
};
|
||||
|
||||
const handleUploadFiles = async (fileList: FileList) => {
|
||||
setError(null);
|
||||
let uploaded = 0;
|
||||
for (const file of Array.from(fileList)) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const parts = path.split("/");
|
||||
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
|
||||
if (file.size > 1_000_000) continue;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
|
||||
uploaded++;
|
||||
} catch { /* skip binary */ }
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(`Uploaded ${uploaded} files`, "success");
|
||||
loadFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
setError(null);
|
||||
let deleted = 0;
|
||||
for (const f of files) {
|
||||
if (f.dir) continue;
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/files/${f.path}`);
|
||||
deleted++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setFileContent("");
|
||||
setEditContent("");
|
||||
showToast(`Deleted ${deleted} files`, "info");
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
|
||||
const isDirty = editContent !== fileContent;
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-zinc-500">Loading files...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/40 bg-zinc-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={root}
|
||||
onChange={(e) => {
|
||||
setRoot(e.target.value);
|
||||
setSelectedFile(null);
|
||||
setFileContent("");
|
||||
setEditContent("");
|
||||
}}
|
||||
className="text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 rounded px-1.5 py-0.5 outline-none"
|
||||
>
|
||||
<option value="/configs">/configs</option>
|
||||
<option value="/home">/home</option>
|
||||
<option value="/workspace">/workspace</option>
|
||||
<option value="/plugins">/plugins</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-zinc-500">{files.filter((f) => !f.dir).length} files</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{root === "/configs" && (
|
||||
<>
|
||||
<button onClick={() => setShowNewFile(true)} className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
|
||||
+ New
|
||||
</button>
|
||||
<input
|
||||
ref={uploadRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleUploadFiles(e.target.files)}
|
||||
/>
|
||||
<button onClick={() => uploadRef.current?.click()} className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleDownloadAll} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
|
||||
Export
|
||||
</button>
|
||||
{root === "/configs" && (
|
||||
<button onClick={() => setShowDeleteAll(true)} className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => loadFiles()} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete all confirmation */}
|
||||
{showDeleteAll && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-red-300">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">
|
||||
Delete All
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-amber-300">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-zinc-800/40 overflow-y-auto shrink-0">
|
||||
{/* New file input */}
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-zinc-800/40">
|
||||
<input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createFile()}
|
||||
placeholder="path/file.md"
|
||||
autoFocus
|
||||
className="w-full bg-zinc-800 border border-zinc-600 rounded px-1.5 py-0.5 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-zinc-600 text-center">
|
||||
No config files yet
|
||||
</div>
|
||||
) : (
|
||||
<TreeView
|
||||
nodes={tree}
|
||||
selectedPath={selectedFile}
|
||||
onSelect={openFile}
|
||||
onDelete={root === "/configs" ? requestDeleteFile : () => {}}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
loadingDir={loadingDir}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
{/* File header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/20">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-300 truncate">{selectedFile}</span>
|
||||
{isDirty && <span className="text-[9px] text-amber-400">modified</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{success && <span className="text-[9px] text-emerald-400">{success}</span>}
|
||||
<button
|
||||
onClick={handleDownloadFile}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
title="Download file"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{root === "/configs" && (
|
||||
<button
|
||||
onClick={saveFile}
|
||||
disabled={!isDirty || saving}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 disabled:opacity-30"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor area */}
|
||||
{loadingFile ? (
|
||||
<div className="p-4 text-xs text-zinc-500">Loading...</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={editContent}
|
||||
readOnly={root !== "/configs"}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
saveFile();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const val = editContent;
|
||||
const updated = val.substring(0, start) + " " + val.substring(end);
|
||||
setEditContent(updated);
|
||||
requestAnimationFrame(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.selectionStart = editorRef.current.selectionEnd = start + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="flex-1 w-full bg-zinc-950 p-3 text-[11px] font-mono text-zinc-200 leading-relaxed resize-none focus:outline-none"
|
||||
style={{ tabSize: 2 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl opacity-20 mb-2">📄</div>
|
||||
<p className="text-[10px] text-zinc-600">Select a file to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tree building utilities — exported for testing
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children: TreeNode[];
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function buildTree(files: FileEntry[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
const dirMap = new Map<string, TreeNode>();
|
||||
|
||||
// Sort: dirs first, then alphabetical
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
if (a.dir !== b.dir) return a.dir ? -1 : 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
for (const file of sorted) {
|
||||
const parts = file.path.split("/");
|
||||
if (parts.length === 1) {
|
||||
// Check if already exists in dirMap (e.g. created by a nested child earlier)
|
||||
if (file.dir && dirMap.has(file.path)) continue;
|
||||
const node: TreeNode = { name: parts[0], path: file.path, isDir: file.dir, children: [], size: file.size };
|
||||
root.push(node);
|
||||
if (file.dir) dirMap.set(file.path, node);
|
||||
} else {
|
||||
// Find or create parent dirs
|
||||
let parentChildren = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const dirPath = parts.slice(0, i + 1).join("/");
|
||||
let dirNode = dirMap.get(dirPath);
|
||||
if (!dirNode) {
|
||||
dirNode = { name: parts[i], path: dirPath, isDir: true, children: [], size: 0 };
|
||||
parentChildren.push(dirNode);
|
||||
dirMap.set(dirPath, dirNode);
|
||||
}
|
||||
parentChildren = dirNode.children;
|
||||
}
|
||||
if (file.dir) {
|
||||
const dirPath = file.path;
|
||||
if (!dirMap.has(dirPath)) {
|
||||
const dirNode: TreeNode = { name: parts[parts.length - 1], path: dirPath, isDir: true, children: [], size: 0 };
|
||||
parentChildren.push(dirNode);
|
||||
dirMap.set(dirPath, dirNode);
|
||||
}
|
||||
} else {
|
||||
parentChildren.push({
|
||||
name: parts[parts.length - 1],
|
||||
path: file.path,
|
||||
isDir: false,
|
||||
children: [],
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
interface TreeCallbacks {
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
expandedDirs: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
loadingDir: string | null;
|
||||
}
|
||||
|
||||
function TreeView({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth = 0,
|
||||
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeItem({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth,
|
||||
}: TreeCallbacks & { node: TreeNode; depth: number }) {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const expanded = expandedDirs.has(node.path);
|
||||
const isLoading = loadingDir === node.path;
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-zinc-800/40 transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
<span className="text-[9px] text-zinc-500 w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span className="text-[10px]">📁</span>
|
||||
<span className="text-[10px] text-zinc-400 flex-1">{node.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.path);
|
||||
}}
|
||||
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<TreeView
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-blue-900/30 text-zinc-100" : "hover:bg-zinc-800/40 text-zinc-400"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.path);
|
||||
}}
|
||||
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
canvas/src/components/tabs/MemoryTab.tsx
Normal file
337
canvas/src/components/tabs/MemoryTab.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
expires_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const AWARENESS_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
|
||||
|
||||
export function MemoryTab({ workspaceId }: Props) {
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAwareness, setShowAwareness] = useState(true);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newTTL, setNewTTL] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const awarenessUrl = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return AWARENESS_BASE_URL;
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const awarenessStatus = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
return url.origin.includes("localhost") ? "local" : url.hostname;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMemory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
setEntries([]);
|
||||
setError(e instanceof Error ? e.message : "Failed to load memory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemory();
|
||||
}, [loadMemory]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError(null);
|
||||
if (!newKey.trim()) {
|
||||
setError("Key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = newValue;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
|
||||
if (newTTL) {
|
||||
const ttl = parseInt(newTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setNewTTL("");
|
||||
setShowAdd(false);
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
if (expanded === key) setExpanded(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete entry");
|
||||
}
|
||||
};
|
||||
|
||||
const openAwareness = () => {
|
||||
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-zinc-500">Loading memory...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{error && !showAdd && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-200">Awareness dashboard</div>
|
||||
<p className="text-[10px] text-zinc-500">
|
||||
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAwareness ? (
|
||||
AWARENESS_BASE_URL ? (
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
|
||||
<iframe
|
||||
title="Awareness dashboard"
|
||||
src={awarenessUrl}
|
||||
className="h-[520px] w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
Set <code className="font-mono text-zinc-300">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-zinc-200">Awareness dashboard is collapsed</p>
|
||||
<p className="text-[10px] text-zinc-500 truncate">
|
||||
Workspace context stays linked through <span className="font-mono text-zinc-400">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 rounded-xl border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-[10px] text-zinc-400 sm:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-zinc-500">Status</span>
|
||||
<span className="font-medium text-emerald-300">Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-zinc-500">Mode</span>
|
||||
<span className="font-medium text-zinc-200">{awarenessStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="uppercase tracking-[0.18em] text-zinc-500">Workspace</span>
|
||||
<span className="font-mono text-zinc-300 truncate">{workspaceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-zinc-800/60 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-200">Workspace KV memory</div>
|
||||
<p className="text-[10px] text-zinc-500">
|
||||
Native platform key-value memory for workspace <span className="font-mono text-zinc-400">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvanced && showAdd && (
|
||||
<div className="bg-zinc-800 rounded p-3 space-y-2 border border-zinc-700">
|
||||
<input
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<textarea
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder='Value (JSON or plain text)'
|
||||
rows={3}
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs font-mono text-zinc-100 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
<input
|
||||
value={newTTL}
|
||||
onChange={(e) => setNewTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (optional)"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced ? (
|
||||
entries.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500 text-center py-4">No memory entries</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="bg-zinc-800 rounded border border-zinc-700">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-mono text-blue-400">{entry.key}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.expires_at && (
|
||||
<span className="text-[9px] text-zinc-500">
|
||||
TTL {new Date(entry.expires_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{expanded === entry.key ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === entry.key && (
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<pre className="text-[10px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-500">
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-950/30 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-zinc-200">Advanced workspace memory is hidden</p>
|
||||
<p className="text-[10px] text-zinc-500 truncate">
|
||||
KV entries remain available if you need the raw platform store.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
canvas/src/components/tabs/ScheduleTab.tsx
Normal file
360
canvas/src/components/tabs/ScheduleTab.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
run_count: number;
|
||||
last_status: string;
|
||||
last_error: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function cronToHuman(expr: string): string {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return expr;
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
if (min === "*" && hour === "*") return `Every minute`;
|
||||
if (min.startsWith("*/")) return `Every ${min.slice(2)} minutes`;
|
||||
if (hour.startsWith("*/") && min === "0") return `Every ${hour.slice(2)} hours`;
|
||||
if (dom === "*" && mon === "*" && dow === "*" && !hour.startsWith("*/"))
|
||||
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")} UTC`;
|
||||
if (dom === "*" && mon === "*" && dow === "1-5" && !hour.startsWith("*/"))
|
||||
return `Weekdays at ${hour.padStart(2, "0")}:${min.padStart(2, "0")} UTC`;
|
||||
return expr;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 0) {
|
||||
const future = -diff;
|
||||
if (future < 60000) return `in ${Math.round(future / 1000)}s`;
|
||||
if (future < 3600000) return `in ${Math.round(future / 60000)}m`;
|
||||
if (future < 86400000) return `in ${Math.round(future / 3600000)}h`;
|
||||
return `in ${Math.round(future / 86400000)}d`;
|
||||
}
|
||||
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 ScheduleTab({ workspaceId }: Props) {
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formCron, setFormCron] = useState("0 9 * * *");
|
||||
const [formTimezone, setFormTimezone] = useState("UTC");
|
||||
const [formPrompt, setFormPrompt] = useState("");
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
|
||||
setSchedules(data);
|
||||
} catch {
|
||||
setSchedules([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
const interval = setInterval(fetchSchedules, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName("");
|
||||
setFormCron("0 9 * * *");
|
||||
setFormTimezone("UTC");
|
||||
setFormPrompt("");
|
||||
setFormEnabled(true);
|
||||
setEditId(null);
|
||||
setShowForm(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
try {
|
||||
if (editId) {
|
||||
await api.patch(`/workspaces/${workspaceId}/schedules/${editId}`, {
|
||||
name: formName,
|
||||
cron_expr: formCron,
|
||||
timezone: formTimezone,
|
||||
prompt: formPrompt,
|
||||
enabled: formEnabled,
|
||||
});
|
||||
} else {
|
||||
await api.post(`/workspaces/${workspaceId}/schedules`, {
|
||||
name: formName,
|
||||
cron_expr: formCron,
|
||||
timezone: formTimezone,
|
||||
prompt: formPrompt,
|
||||
enabled: formEnabled,
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
fetchSchedules();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save schedule");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!window.confirm(`Delete schedule "${name || "Unnamed"}"? This cannot be undone.`)) return;
|
||||
await api.del(`/workspaces/${workspaceId}/schedules/${id}`);
|
||||
fetchSchedules();
|
||||
};
|
||||
|
||||
const handleToggle = async (sched: Schedule) => {
|
||||
await api.patch(`/workspaces/${workspaceId}/schedules/${sched.id}`, {
|
||||
enabled: !sched.enabled,
|
||||
});
|
||||
fetchSchedules();
|
||||
};
|
||||
|
||||
const handleEdit = (sched: Schedule) => {
|
||||
setFormName(sched.name);
|
||||
setFormCron(sched.cron_expr);
|
||||
setFormTimezone(sched.timezone);
|
||||
setFormPrompt(sched.prompt);
|
||||
setFormEnabled(sched.enabled);
|
||||
setEditId(sched.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleRunNow = async (sched: Schedule) => {
|
||||
try {
|
||||
const result = await api.post<{ prompt: string }>(`/workspaces/${workspaceId}/schedules/${sched.id}/run`, {});
|
||||
await api.post(`/workspaces/${workspaceId}/a2a`, {
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: `manual-cron-${Date.now()}`,
|
||||
parts: [{ kind: "text", text: result.prompt }],
|
||||
},
|
||||
},
|
||||
});
|
||||
fetchSchedules();
|
||||
} catch {
|
||||
setError("Failed to run schedule");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-[10px] text-zinc-500">Loading schedules...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/50">
|
||||
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
Schedules
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowForm(true); }}
|
||||
className="text-[9px] px-2 py-0.5 bg-blue-600/20 text-blue-400 rounded hover:bg-blue-600/30 transition-colors"
|
||||
>
|
||||
+ Add Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="p-3 border-b border-zinc-800/50 bg-zinc-900/50 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schedule name (e.g., Daily security scan)"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[8px] text-zinc-500 block mb-0.5">Cron Expression</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formCron}
|
||||
onChange={(e) => setFormCron(e.target.value)}
|
||||
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 font-mono"
|
||||
/>
|
||||
<div className="text-[8px] text-zinc-600 mt-0.5">
|
||||
{cronToHuman(formCron)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-[8px] text-zinc-500 block mb-0.5">Timezone</label>
|
||||
<select
|
||||
value={formTimezone}
|
||||
onChange={(e) => setFormTimezone(e.target.value)}
|
||||
className="w-full text-[9px] bg-zinc-800 border border-zinc-700 rounded px-1 py-1 text-zinc-200"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">US Eastern</option>
|
||||
<option value="America/Chicago">US Central</option>
|
||||
<option value="America/Denver">US Mountain</option>
|
||||
<option value="America/Los_Angeles">US Pacific</option>
|
||||
<option value="Europe/London">London</option>
|
||||
<option value="Europe/Berlin">Berlin</option>
|
||||
<option value="Asia/Tokyo">Tokyo</option>
|
||||
<option value="Asia/Shanghai">Shanghai</option>
|
||||
<option value="Australia/Sydney">Sydney</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] text-zinc-500 block mb-0.5">Prompt / Task</label>
|
||||
<textarea
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
placeholder="What should the agent do on this schedule?"
|
||||
rows={3}
|
||||
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 placeholder:text-zinc-600 resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[9px] text-zinc-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="rounded border-zinc-600"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className="text-[9px] text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!formCron || !formPrompt}
|
||||
className="text-[9px] px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{editId ? "Update" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="text-[9px] px-3 py-1 bg-zinc-800 text-zinc-400 rounded hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[8px] text-zinc-600 space-y-0.5">
|
||||
<div>Common patterns:</div>
|
||||
<div className="font-mono">{"0 9 * * *"} — Daily at 9:00 AM</div>
|
||||
<div className="font-mono">{"*/30 * * * *"} — Every 30 minutes</div>
|
||||
<div className="font-mono">{"0 */4 * * *"} — Every 4 hours</div>
|
||||
<div className="font-mono">{"0 9 * * 1-5"} — Weekdays at 9:00 AM</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{schedules.length === 0 && !showForm ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="text-2xl mb-2">⏲</div>
|
||||
<div className="text-[10px] text-zinc-400 mb-1">No schedules yet</div>
|
||||
<div className="text-[9px] text-zinc-600">
|
||||
Add a schedule to run tasks automatically — daily scans, periodic reports, standup reminders.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
schedules.map((sched) => (
|
||||
<div
|
||||
key={sched.id}
|
||||
className={`px-3 py-2 border-b border-zinc-800/30 ${
|
||||
!sched.enabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleToggle(sched)}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
sched.last_status === "error"
|
||||
? "bg-red-400"
|
||||
: sched.last_status === "ok"
|
||||
? "bg-emerald-400"
|
||||
: "bg-zinc-600"
|
||||
}`}
|
||||
title={sched.enabled ? "Click to disable" : "Click to enable"}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-zinc-200 truncate">
|
||||
{sched.name || "Unnamed schedule"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-zinc-500 mt-0.5 font-mono">
|
||||
{cronToHuman(sched.cron_expr)}
|
||||
{sched.timezone !== "UTC" && (
|
||||
<span className="text-zinc-600"> ({sched.timezone})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] text-zinc-600 mt-0.5 truncate">
|
||||
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[8px] text-zinc-600">
|
||||
<span>Last: {relativeTime(sched.last_run_at)}</span>
|
||||
<span>Next: {relativeTime(sched.next_run_at)}</span>
|
||||
<span>Runs: {sched.run_count}</span>
|
||||
</div>
|
||||
{sched.last_error && (
|
||||
<div className="text-[8px] text-red-400/70 mt-0.5 truncate">
|
||||
Error: {sched.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleRunNow(sched)}
|
||||
className="text-[8px] px-1.5 py-0.5 text-blue-400 hover:bg-blue-600/20 rounded transition-colors"
|
||||
title="Run now"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(sched)}
|
||||
className="text-[8px] px-1.5 py-0.5 text-zinc-400 hover:bg-zinc-700 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(sched.id, sched.name)}
|
||||
className="text-[8px] px-1.5 py-0.5 text-red-400 hover:bg-red-600/20 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
canvas/src/components/tabs/SkillsTab.tsx
Normal file
424
canvas/src/components/tabs/SkillsTab.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, summarizeWorkspaceCapabilities, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { showToast } from "../Toaster";
|
||||
|
||||
interface Props {
|
||||
data: WorkspaceNodeData;
|
||||
}
|
||||
|
||||
interface SkillEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
skills: string[];
|
||||
// Declared supported runtimes (e.g. ["claude_code", "deepagents"]).
|
||||
// Empty / absent = "unspecified, try it".
|
||||
runtimes?: string[];
|
||||
// Only present on /workspaces/:id/plugins responses — true if the
|
||||
// plugin declared support for the workspace's current runtime (or
|
||||
// declared no runtimes at all). Lets us grey out inert installs.
|
||||
supported_on_runtime?: boolean;
|
||||
}
|
||||
|
||||
interface SourceSchemesResponse {
|
||||
schemes: string[];
|
||||
}
|
||||
|
||||
// Delay before reloading installed plugins after install/uninstall (workspace restarts)
|
||||
const PLUGIN_RELOAD_DELAY_MS = 15_000;
|
||||
|
||||
export function SkillsTab({ data }: Props) {
|
||||
const capability = summarizeWorkspaceCapabilities(data);
|
||||
const skills = useMemo(() => extractSkills(data.agentCard), [data.agentCard]);
|
||||
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
||||
const promotionTask = data.currentTask.startsWith("Skill promotion:");
|
||||
|
||||
const [registry, setRegistry] = useState<PluginInfo[]>([]);
|
||||
const [installed, setInstalled] = useState<PluginInfo[]>([]);
|
||||
const [sourceSchemes, setSourceSchemes] = useState<string[]>([]);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [uninstalling, setUninstalling] = useState<string | null>(null);
|
||||
const [showRegistry, setShowRegistry] = useState(false);
|
||||
const [customSource, setCustomSource] = useState("");
|
||||
const mountedRef = useRef(true);
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const workspaceId = data.id;
|
||||
|
||||
const loadInstalled = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>(`/workspaces/${workspaceId}/plugins`);
|
||||
if (mountedRef.current) setInstalled(result);
|
||||
} catch { /* ignore */ }
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadRegistry = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>("/plugins");
|
||||
if (mountedRef.current) setRegistry(result);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadSourceSchemes = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
|
||||
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
|
||||
} catch { /* ignore — falls back to "local only" UX */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInstalled();
|
||||
loadRegistry();
|
||||
loadSourceSchemes();
|
||||
}, [loadInstalled, loadRegistry, loadSourceSchemes]);
|
||||
|
||||
const installedNames = useMemo(() => new Set(installed.map((p) => p.name)), [installed]);
|
||||
|
||||
// Install always goes through the source-based API. For registry
|
||||
// plugins we build the local:// source on the fly; custom sources
|
||||
// (github://, clawhub://, …) are typed into the input below.
|
||||
const installFromSource = async (source: string, labelOverride?: string) => {
|
||||
const label = labelOverride ?? source;
|
||||
setInstalling(label);
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/plugins`, { source });
|
||||
showToast(`Installed ${label} — restarting workspace`, "success");
|
||||
reloadTimerRef.current = setTimeout(() => loadInstalled(), PLUGIN_RELOAD_DELAY_MS);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Install failed", "error");
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = (pluginName: string) => installFromSource(`local://${pluginName}`, pluginName);
|
||||
|
||||
const handleInstallCustom = async () => {
|
||||
const source = customSource.trim();
|
||||
if (!source) return;
|
||||
await installFromSource(source);
|
||||
setCustomSource("");
|
||||
};
|
||||
|
||||
const handleUninstall = async (pluginName: string) => {
|
||||
setUninstalling(pluginName);
|
||||
try {
|
||||
await api.del(`/workspaces/${data.id}/plugins/${pluginName}`);
|
||||
showToast(`Removed ${pluginName} — restarting workspace`, "success");
|
||||
setInstalled((prev) => prev.filter((p) => p.name !== pluginName));
|
||||
reloadTimerRef.current = setTimeout(() => loadInstalled(), PLUGIN_RELOAD_DELAY_MS);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Uninstall failed", "error");
|
||||
} finally {
|
||||
setUninstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Plugins section */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">Plugins</div>
|
||||
<h3 className="mt-1 text-sm font-semibold text-zinc-100">
|
||||
{installed.length} installed
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(!showRegistry)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
>
|
||||
{showRegistry ? "Hide Registry" : "+ Install Plugin"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Installed plugins */}
|
||||
{installed.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{installed.map((p) => {
|
||||
// Plugin was installed but does NOT declare support for
|
||||
// the workspace's current runtime — grey it out so users
|
||||
// see it's inert. Happens after a runtime change or when
|
||||
// someone installs a runtime-specific plugin on a wrong
|
||||
// workspace.
|
||||
const inert = p.supported_on_runtime === false;
|
||||
return (
|
||||
<div
|
||||
key={p.name}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 ${
|
||||
inert
|
||||
? "border-amber-800/40 bg-amber-950/10 opacity-70"
|
||||
: "border-zinc-800/60 bg-zinc-950/40"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-zinc-200">{p.name}</span>
|
||||
{p.version && <span className="text-[9px] text-zinc-600">v{p.version}</span>}
|
||||
{inert && (
|
||||
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[8px] text-amber-300">
|
||||
inert on this runtime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.description && <div className="text-[10px] text-zinc-500 truncate">{p.description}</div>}
|
||||
{p.skills && p.skills.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{p.skills.slice(0, 4).map((s) => (
|
||||
<span key={s} className="rounded-full bg-zinc-800/60 px-1.5 py-0.5 text-[8px] text-zinc-400">{s}</span>
|
||||
))}
|
||||
{p.skills.length > 4 && (
|
||||
<span className="text-[8px] text-zinc-600">+{p.skills.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUninstall(p.name)}
|
||||
disabled={uninstalling === p.name}
|
||||
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[9px] text-red-400 hover:bg-red-900/30 disabled:opacity-30"
|
||||
>
|
||||
{uninstalling === p.name ? "..." : "Remove"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin registry (expandable) */}
|
||||
{showRegistry && (
|
||||
<div className="mt-3 border-t border-zinc-800/40 pt-3">
|
||||
{/* Install from any source (github://, clawhub://, …) */}
|
||||
<div className="mb-3 rounded-lg border border-zinc-800/60 bg-zinc-950/40 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-[0.2em] text-zinc-600">
|
||||
Install from source
|
||||
</div>
|
||||
{sourceSchemes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sourceSchemes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="rounded-full border border-zinc-700/50 bg-zinc-900/50 px-1.5 py-0.5 text-[8px] text-zinc-500"
|
||||
>
|
||||
{s}://
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={customSource}
|
||||
onChange={(e) => setCustomSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !installing) handleInstallCustom();
|
||||
}}
|
||||
placeholder="e.g. github://owner/repo#v1.0"
|
||||
spellCheck={false}
|
||||
className="flex-1 rounded border border-zinc-700 bg-zinc-950 px-2 py-1 text-[10px] text-zinc-200 placeholder:text-zinc-600 focus:border-violet-600 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleInstallCustom}
|
||||
disabled={!customSource.trim() || installing !== null}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-1 text-[9px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
>
|
||||
{installing === customSource.trim() ? "Installing..." : "Install"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-zinc-600">
|
||||
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[9px] uppercase tracking-[0.2em] text-zinc-600 mb-2">Available plugins</div>
|
||||
{registry.length === 0 ? (
|
||||
<div className="text-[10px] text-zinc-600">No plugins in registry</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{registry.map((p) => {
|
||||
const isInstalled = installedNames.has(p.name);
|
||||
return (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-lg border border-zinc-800/40 bg-zinc-950/30 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-zinc-300">{p.name}</span>
|
||||
{p.version && <span className="text-[9px] text-zinc-600">v{p.version}</span>}
|
||||
</div>
|
||||
{p.description && <div className="text-[10px] text-zinc-500 truncate">{p.description}</div>}
|
||||
{p.tags && p.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{p.tags.map((t) => (
|
||||
<span key={t} className="rounded-full border border-zinc-700/40 px-1.5 py-0.5 text-[8px] text-zinc-500">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{p.runtimes && p.runtimes.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{p.runtimes.map((r) => (
|
||||
<span key={r} className="rounded-full border border-blue-800/40 bg-blue-950/20 px-1.5 py-0.5 text-[8px] text-blue-300">{r}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isInstalled ? (
|
||||
<span className="shrink-0 text-[9px] text-emerald-500">Installed</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleInstall(p.name)}
|
||||
disabled={installing === p.name}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[9px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
>
|
||||
{installing === p.name ? "Installing..." : "Install"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skills section */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">Workspace skills</div>
|
||||
<h3 className="mt-1 text-sm font-semibold text-zinc-100">Installed skills</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MetaPill label="Count" value={String(capability.skillCount)} />
|
||||
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
Live skill directory from the Agent Card — updates when the workspace hot-reloads skills.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setPanelTab("config")}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-950 px-3 py-1 text-[10px] text-zinc-300 hover:bg-zinc-900"
|
||||
>
|
||||
Open Config
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPanelTab("files")}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-950 px-3 py-1 text-[10px] text-zinc-300 hover:bg-zinc-900"
|
||||
>
|
||||
Open Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promotionTask && (
|
||||
<div className="rounded-xl border border-violet-800/30 bg-violet-950/20 p-3 text-[11px] text-violet-200/90">
|
||||
A skill promotion is currently in flight. The workspace is compressing a repeatable workflow into
|
||||
a new skill package.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/40 p-6 text-center">
|
||||
<div className="text-sm text-zinc-100">No skills loaded</div>
|
||||
<p className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
<div key={skill.id} className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-100">{skill.name}</div>
|
||||
<div className="mt-0.5 text-[10px] font-mono text-zinc-500">{skill.id}</div>
|
||||
</div>
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{skill.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[9px] text-zinc-400"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.description && (
|
||||
<p className="mt-2 text-[11px] leading-5 text-zinc-400">{skill.description}</p>
|
||||
)}
|
||||
|
||||
{skill.examples.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[9px] uppercase tracking-[0.2em] text-zinc-600">Examples</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{skill.examples.slice(0, 2).map((example, index) => (
|
||||
<div
|
||||
key={`${skill.id}-${index}`}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-2 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{example}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
|
||||
if (!agentCard) return [];
|
||||
const rawSkills = agentCard.skills;
|
||||
if (!Array.isArray(rawSkills)) return [];
|
||||
|
||||
return rawSkills
|
||||
.map((skill: Record<string, unknown>) => ({
|
||||
id: String(skill.id || skill.name || ""),
|
||||
name: String(skill.name || skill.id || "Unnamed skill"),
|
||||
description: String(skill.description || ""),
|
||||
tags: Array.isArray(skill.tags) ? skill.tags.map((tag) => String(tag)) : [],
|
||||
examples: Array.isArray(skill.examples) ? skill.examples.map((example) => String(example)) : [],
|
||||
}))
|
||||
.filter((skill) => skill.id.length > 0);
|
||||
}
|
||||
|
||||
function MetaPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-zinc-700/60 bg-zinc-950/60 px-2 py-1 text-[9px] text-zinc-300">
|
||||
<span className="uppercase tracking-[0.18em] text-[8px] text-zinc-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
159
canvas/src/components/tabs/TerminalTab.tsx
Normal file
159
canvas/src/components/tabs/TerminalTab.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.replace("/ws", "") || "ws://localhost:8080";
|
||||
|
||||
export function TerminalTab({ workspaceId }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">("disconnected");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [connectKey, setConnectKey] = useState(0);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
const el = containerRef.current;
|
||||
if (!el || cancelled) return;
|
||||
|
||||
const { Terminal } = await import("xterm");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
if (cancelled) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: "#18181b",
|
||||
foreground: "#e4e4e7",
|
||||
cursor: "#3b82f6",
|
||||
selectionBackground: "#3b82f644",
|
||||
},
|
||||
fontFamily: "JetBrains Mono, Menlo, Monaco, monospace",
|
||||
fontSize: 12,
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(el);
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
|
||||
// Connect WebSocket
|
||||
setStatus("connecting");
|
||||
const wsUrl = `${WS_URL}/workspaces/${workspaceId}/terminal`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
wsRef.current = socket;
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
socket.onopen = () => {
|
||||
if (cancelled) return;
|
||||
setStatus("connected");
|
||||
setErrorMsg(null);
|
||||
term.writeln("\x1b[32mConnected to workspace shell\x1b[0m");
|
||||
term.writeln("");
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
term.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (cancelled) return;
|
||||
setStatus("disconnected");
|
||||
term.writeln("");
|
||||
term.writeln("\x1b[33mSession ended\x1b[0m");
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setStatus("error");
|
||||
setErrorMsg("Failed to connect — is the workspace container running?");
|
||||
};
|
||||
|
||||
term.onData((data: string) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => fitAddon.fit());
|
||||
observer.observe(el);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [workspaceId, connectKey, cleanup]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
cleanup();
|
||||
setErrorMsg(null);
|
||||
setConnectKey((k) => k + 1);
|
||||
}, [cleanup]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
status === "connected" ? "bg-green-500" :
|
||||
status === "connecting" ? "bg-yellow-500 animate-pulse" :
|
||||
status === "error" ? "bg-red-500" : "bg-zinc-500"
|
||||
}`} />
|
||||
<span className="text-[10px] text-zinc-400">
|
||||
{status === "connected" ? "Shell active" :
|
||||
status === "connecting" ? "Connecting..." :
|
||||
status === "error" ? "Connection failed" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
{(status === "disconnected" || status === "error") && (
|
||||
<button
|
||||
onClick={reconnect}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{errorMsg && (
|
||||
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal */}
|
||||
<div ref={containerRef} className="flex-1 p-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
canvas/src/components/tabs/TracesTab.tsx
Normal file
157
canvas/src/components/tabs/TracesTab.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface Trace {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
latency?: number;
|
||||
input?: Record<string, unknown> | string;
|
||||
output?: Record<string, unknown> | string;
|
||||
status?: string;
|
||||
totalCost?: number;
|
||||
usage?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
total?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function TracesTab({ workspaceId }: Props) {
|
||||
const [traces, setTraces] = useState<Trace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<{ data?: Trace[] }>(`/workspaces/${workspaceId}/traces`);
|
||||
setTraces(res.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load traces");
|
||||
setTraces([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTraces();
|
||||
}, [loadTraces]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-zinc-500">Loading traces...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-400">{traces.length} traces</span>
|
||||
<button onClick={loadTraces} className="text-[10px] text-zinc-500 hover:text-zinc-300">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traces.length === 0 && !error ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-2xl opacity-20 mb-2">📊</div>
|
||||
<p className="text-xs text-zinc-600">No traces yet</p>
|
||||
<p className="text-[10px] text-zinc-700 mt-1">
|
||||
Set LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY to enable tracing
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{traces.map((trace) => (
|
||||
<div key={trace.id} className="bg-zinc-800/40 border border-zinc-700/40 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === trace.id ? null : trace.id)}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-zinc-800/60 transition-colors"
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
trace.status === "ERROR" ? "bg-red-400" : "bg-emerald-400"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-zinc-200 truncate">{trace.name || "trace"}</div>
|
||||
<div className="text-[9px] text-zinc-500">{formatTime(trace.timestamp)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trace.latency != null && (
|
||||
<span className="text-[9px] text-zinc-500 tabular-nums">
|
||||
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
|
||||
</span>
|
||||
)}
|
||||
{trace.usage?.total != null && (
|
||||
<span className="text-[9px] text-zinc-600 tabular-nums">
|
||||
{trace.usage.total} tok
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
{expanded === trace.id ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === trace.id && (
|
||||
<div className="px-3 pb-2 space-y-2 border-t border-zinc-700/30">
|
||||
{trace.input && (
|
||||
<div>
|
||||
<div className="text-[9px] text-zinc-500 uppercase tracking-wider mt-2 mb-1">Input</div>
|
||||
<pre className="text-[9px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.output && (
|
||||
<div>
|
||||
<div className="text-[9px] text-zinc-500 uppercase tracking-wider mb-1">Output</div>
|
||||
<pre className="text-[9px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.totalCost != null && (
|
||||
<div className="text-[9px] text-zinc-500">
|
||||
Cost: ${trace.totalCost.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[8px] text-zinc-600 font-mono select-all">
|
||||
{trace.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
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 ago`;
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
175
canvas/src/components/tabs/chat/AgentCommsPanel.tsx
Normal file
175
canvas/src/components/tabs/chat/AgentCommsPanel.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { WS_URL } from "@/store/socket";
|
||||
import { extractResponseText, extractRequestText } from "./message-parser";
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
activity_type: string;
|
||||
source_id: string | null;
|
||||
target_id: string | null;
|
||||
method: string | null;
|
||||
summary: string | null;
|
||||
request_body: Record<string, unknown> | null;
|
||||
response_body: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CommMessage {
|
||||
id: string;
|
||||
direction: "in" | "out";
|
||||
peerName: string;
|
||||
peerId: string;
|
||||
text: string;
|
||||
responseText: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function resolveName(id: string): string {
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
||||
}
|
||||
|
||||
function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | null {
|
||||
const isOutgoing = entry.activity_type === "a2a_send";
|
||||
const peerId = isOutgoing ? (entry.target_id || "") : (entry.source_id || "");
|
||||
if (!peerId) return null;
|
||||
|
||||
const text = extractRequestText(entry.request_body) || entry.summary || "";
|
||||
const responseText = entry.response_body ? extractResponseText(entry.response_body) : null;
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
direction: isOutgoing ? "out" : "in",
|
||||
peerName: resolveName(peerId),
|
||||
peerId,
|
||||
text,
|
||||
responseText,
|
||||
timestamp: entry.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
||||
const [messages, setMessages] = useState<CommMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Dedup by timestamp+type+peer to handle API load + WebSocket race
|
||||
const seenKeys = useRef(new Set<string>());
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load history
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.get<ActivityEntry[]>(`/workspaces/${workspaceId}/activity?source=agent&limit=50`)
|
||||
.then((entries) => {
|
||||
const filtered = entries
|
||||
.filter((e) => e.activity_type === "a2a_send" || e.activity_type === "a2a_receive")
|
||||
.reverse();
|
||||
const msgs: CommMessage[] = [];
|
||||
for (const e of filtered) {
|
||||
const m = toCommMessage(e, workspaceId);
|
||||
if (m) {
|
||||
const key = `${m.timestamp}:${m.direction}:${m.peerId}`;
|
||||
msgs.push(m);
|
||||
seenKeys.current.add(key);
|
||||
}
|
||||
}
|
||||
setMessages(msgs);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [workspaceId]);
|
||||
|
||||
// Live updates via WebSocket
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
ws.onerror = () => {
|
||||
console.warn("AgentCommsPanel WS error");
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.event === "ACTIVITY_LOGGED" && msg.workspace_id === workspaceId) {
|
||||
const p = msg.payload || {};
|
||||
const type = p.activity_type as string;
|
||||
const sourceId = p.source_id as string | null;
|
||||
if (!sourceId) return; // canvas-initiated, not agent comms
|
||||
if (type !== "a2a_send" && type !== "a2a_receive") return;
|
||||
|
||||
const entry: ActivityEntry = {
|
||||
id: p.id as string || crypto.randomUUID(),
|
||||
activity_type: type,
|
||||
source_id: sourceId,
|
||||
target_id: p.target_id as string | null,
|
||||
method: p.method as string | null,
|
||||
summary: p.summary as string | null,
|
||||
request_body: p.request_body as Record<string, unknown> | null,
|
||||
response_body: p.response_body as Record<string, unknown> | null,
|
||||
status: p.status as string || "ok",
|
||||
created_at: msg.timestamp || new Date().toISOString(),
|
||||
};
|
||||
const m = toCommMessage(entry, workspaceId);
|
||||
if (m) {
|
||||
const key = `${m.timestamp}:${m.direction}:${m.peerId}`;
|
||||
if (seenKeys.current.has(key)) return;
|
||||
seenKeys.current.add(key);
|
||||
setMessages((prev) => [...prev, m]);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-xs text-zinc-500 text-center py-8">Loading agent communications...</div>;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No agent-to-agent communications yet.
|
||||
<br />
|
||||
<span className="text-zinc-600">Delegations and peer messages will appear here.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.direction === "out" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.direction === "out"
|
||||
? "bg-cyan-900/30 text-cyan-100 border border-cyan-700/20"
|
||||
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[9px] text-zinc-500 mb-1">
|
||||
{msg.direction === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||
</div>
|
||||
<div className="text-zinc-300">{msg.text || "(no message text)"}</div>
|
||||
{msg.responseText && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/30 text-zinc-400">
|
||||
{msg.responseText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[9px] text-zinc-600 mt-1">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
canvas/src/components/tabs/chat/__tests__/message-parser.test.ts
Normal file
135
canvas/src/components/tabs/chat/__tests__/message-parser.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractRequestText,
|
||||
extractResponseText,
|
||||
extractAgentText,
|
||||
extractTextsFromParts,
|
||||
} from "../message-parser";
|
||||
|
||||
describe("extractRequestText", () => {
|
||||
it("extracts text from standard A2A request_body", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
parts: [{ kind: "text", text: "Hello agent" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractRequestText(body)).toBe("Hello agent");
|
||||
});
|
||||
|
||||
it("returns empty string for null body", () => {
|
||||
expect(extractRequestText(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty object", () => {
|
||||
expect(extractRequestText({})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when params missing", () => {
|
||||
expect(extractRequestText({ other: "data" })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when message missing", () => {
|
||||
expect(extractRequestText({ params: {} })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when parts empty", () => {
|
||||
expect(extractRequestText({ params: { message: { parts: [] } } })).toBe("");
|
||||
});
|
||||
|
||||
it("extracts first part text only", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [
|
||||
{ kind: "text", text: "First" },
|
||||
{ kind: "text", text: "Second" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractRequestText(body)).toBe("First");
|
||||
});
|
||||
|
||||
it("handles non-text parts gracefully", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [{ kind: "image", data: "base64..." }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractRequestText(body)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractResponseText", () => {
|
||||
it("extracts from result string", () => {
|
||||
expect(extractResponseText({ result: "Hello!" })).toBe("Hello!");
|
||||
});
|
||||
|
||||
it("extracts from result.parts[].text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ kind: "text", text: "Response text" }],
|
||||
},
|
||||
};
|
||||
expect(extractResponseText(body)).toBe("Response text");
|
||||
});
|
||||
|
||||
it("extracts from result.parts[].root.text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root text" } }],
|
||||
},
|
||||
};
|
||||
expect(extractResponseText(body)).toBe("Root text");
|
||||
});
|
||||
|
||||
it("extracts from task field", () => {
|
||||
expect(extractResponseText({ task: "Task text" })).toBe("Task text");
|
||||
});
|
||||
|
||||
it("returns empty for empty object", () => {
|
||||
expect(extractResponseText({})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty when result has no parts", () => {
|
||||
expect(extractResponseText({ result: { other: true } })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
{ kind: "text", text: "Hello" },
|
||||
{ kind: "text", text: "World" },
|
||||
];
|
||||
expect(extractTextsFromParts(parts)).toBe("Hello\nWorld");
|
||||
});
|
||||
|
||||
it("extracts text parts with type=text", () => {
|
||||
const parts = [{ type: "text", text: "Legacy format" }];
|
||||
expect(extractTextsFromParts(parts)).toBe("Legacy format");
|
||||
});
|
||||
|
||||
it("returns null for non-array", () => {
|
||||
expect(extractTextsFromParts(null)).toBeNull();
|
||||
expect(extractTextsFromParts(undefined)).toBeNull();
|
||||
expect(extractTextsFromParts("string")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty array", () => {
|
||||
expect(extractTextsFromParts([])).toBeNull();
|
||||
});
|
||||
|
||||
it("filters out non-text parts", () => {
|
||||
const parts = [
|
||||
{ kind: "image", data: "..." },
|
||||
{ kind: "text", text: "Only text" },
|
||||
];
|
||||
expect(extractTextsFromParts(parts)).toBe("Only text");
|
||||
});
|
||||
});
|
||||
2
canvas/src/components/tabs/chat/index.ts
Normal file
2
canvas/src/components/tabs/chat/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { type ChatMessage, createMessage } from "./types";
|
||||
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
|
||||
66
canvas/src/components/tabs/chat/message-parser.ts
Normal file
66
canvas/src/components/tabs/chat/message-parser.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export function extractAgentText(task: Record<string, unknown>): string {
|
||||
try {
|
||||
const directTexts = extractTextsFromParts(task.parts);
|
||||
if (directTexts) return directTexts;
|
||||
|
||||
const artifacts = task.artifacts as Array<Record<string, unknown>> | undefined;
|
||||
if (artifacts && artifacts.length > 0) {
|
||||
const texts = extractTextsFromParts(artifacts[0].parts);
|
||||
if (texts) return texts;
|
||||
}
|
||||
|
||||
const status = task.status as Record<string, unknown> | undefined;
|
||||
if (status?.message) {
|
||||
const msg = status.message as Record<string, unknown>;
|
||||
const texts = extractTextsFromParts(msg.parts);
|
||||
if (texts) return texts;
|
||||
}
|
||||
|
||||
if (typeof task === "string") return task;
|
||||
return "(Could not extract response text)";
|
||||
} catch {
|
||||
return "(Failed to parse response)";
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextsFromParts(parts: unknown): string | null {
|
||||
if (!Array.isArray(parts)) return null;
|
||||
const texts = parts
|
||||
.filter((p: Record<string, unknown>) => p.type === "text" || p.kind === "text")
|
||||
.map((p: Record<string, unknown>) => String(p.text || ""))
|
||||
.filter(Boolean);
|
||||
return texts.length > 0 ? texts.join("\n") : null;
|
||||
}
|
||||
|
||||
/** Extract user message text from an activity log request_body */
|
||||
export function extractRequestText(body: Record<string, unknown> | null): string {
|
||||
if (!body) return "";
|
||||
const params = body.params as Record<string, unknown> | undefined;
|
||||
const msg = params?.message as Record<string, unknown> | undefined;
|
||||
const parts = msg?.parts as Array<Record<string, unknown>> | undefined;
|
||||
return (parts?.[0]?.text as string) || "";
|
||||
}
|
||||
|
||||
/** Extract text from an activity log response_body (multiple possible formats) */
|
||||
export function extractResponseText(body: Record<string, unknown>): string {
|
||||
try {
|
||||
// {result: "text"} — from MCP server delegation logs
|
||||
if (typeof body.result === "string") return body.result;
|
||||
|
||||
// A2A JSON-RPC response: {result: {parts: [{kind: "text", text: "..."}]}}
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
if (result) {
|
||||
const parts = (result.parts || []) as Array<Record<string, unknown>>;
|
||||
for (const p of parts) {
|
||||
const t = (p.text as string) || "";
|
||||
if (t) return t;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
if (root?.text) return root.text as string;
|
||||
}
|
||||
}
|
||||
|
||||
// {task: "text"} — request body format, shouldn't be in response but handle it
|
||||
if (typeof body.task === "string") return body.task;
|
||||
} catch { /* ignore */ }
|
||||
return "";
|
||||
}
|
||||
10
canvas/src/components/tabs/chat/types.ts
Normal file
10
canvas/src/components/tabs/chat/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "agent" | "system";
|
||||
content: string;
|
||||
timestamp: string; // ISO string for serialization
|
||||
}
|
||||
|
||||
export function createMessage(role: ChatMessage["role"], content: string): ChatMessage {
|
||||
return { id: crypto.randomUUID(), role, content, timestamp: new Date().toISOString() };
|
||||
}
|
||||
125
canvas/src/components/tabs/config/form-inputs.tsx
Normal file
125
canvas/src/components/tabs/config/form-inputs.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export interface ConfigData {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
tier: number;
|
||||
model: string;
|
||||
runtime: string;
|
||||
runtime_config?: {
|
||||
model?: string;
|
||||
required_env?: string[];
|
||||
timeout?: number;
|
||||
// Deprecated
|
||||
auth_token_file?: string;
|
||||
};
|
||||
prompt_files: string[];
|
||||
shared_context: string[];
|
||||
skills: string[];
|
||||
tools: string[];
|
||||
a2a: { port: number; streaming: boolean; push_notifications: boolean };
|
||||
delegation: { retry_attempts: number; retry_delay: number; timeout: number; escalate: boolean };
|
||||
sandbox: { backend: string; memory_limit: string; timeout: number };
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ConfigData = {
|
||||
name: "",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
tier: 1,
|
||||
model: "",
|
||||
runtime: "",
|
||||
prompt_files: [],
|
||||
shared_context: [],
|
||||
skills: [],
|
||||
tools: [],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
export function TextInput({ label, value, onChange, placeholder, mono }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 ${mono ? "font-mono" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberInput({ label, value, onChange, min, max }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="accent-blue-500" />
|
||||
<span className="text-[10px] text-zinc-400">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagList({ label, values, onChange, placeholder }: { label: string; values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 font-mono">
|
||||
{v}
|
||||
<button onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-zinc-500 hover:text-red-400">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
onChange([...values, input.trim()]);
|
||||
setInput("");
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type and press Enter"}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-[10px] text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-zinc-800 rounded mb-2">
|
||||
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-400 hover:text-zinc-200 bg-zinc-900/50">
|
||||
<span className="font-medium uppercase tracking-wider">{title}</span>
|
||||
<span>{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
canvas/src/components/tabs/config/index.ts
Normal file
3
canvas/src/components/tabs/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./form-inputs";
|
||||
export { parseYaml, toYaml } from "./yaml-utils";
|
||||
export { SecretsSection } from "./secrets-section";
|
||||
318
canvas/src/components/tabs/config/secrets-section.tsx
Normal file
318
canvas/src/components/tabs/config/secrets-section.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Section } from "./form-inputs";
|
||||
import { markAllWorkspacesNeedRestart } from "@/lib/canvas-actions";
|
||||
|
||||
interface SecretEntry {
|
||||
key: string;
|
||||
has_value: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
scope?: "global" | "workspace";
|
||||
}
|
||||
|
||||
const COMMON_KEYS = [
|
||||
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API Key" },
|
||||
{ key: "OPENAI_API_KEY", label: "OpenAI API Key" },
|
||||
{ key: "GOOGLE_API_KEY", label: "Google AI API Key" },
|
||||
{ key: "SERP_API_KEY", label: "SERP API Key" },
|
||||
{ key: "MODEL_PROVIDER", label: "Model Override (e.g. anthropic:claude-sonnet-4-6)" },
|
||||
];
|
||||
|
||||
function ScopeBadge({ scope }: { scope: "global" | "workspace" | "override" }) {
|
||||
if (scope === "global") {
|
||||
return <span className="text-[8px] text-amber-400 bg-amber-900/30 px-1.5 py-0.5 rounded" title="Inherited from global secrets">Global</span>;
|
||||
}
|
||||
if (scope === "override") {
|
||||
return <span className="text-[8px] text-purple-400 bg-purple-900/30 px-1.5 py-0.5 rounded" title="Overrides global secret">Override</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelete }: {
|
||||
label: string; secretKey: string; isSet: boolean;
|
||||
scope?: "global" | "workspace" | "override";
|
||||
globalMode?: boolean;
|
||||
onSave: (value: string) => void; onDelete: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const actionLabel = (): string => {
|
||||
if (editing) return "Cancel";
|
||||
if (!isSet) return "Set";
|
||||
if (globalMode) return "Update";
|
||||
if (scope === "global") return "Override";
|
||||
return "Update";
|
||||
};
|
||||
|
||||
const isPlaintext = secretKey === "MODEL_PROVIDER";
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800/50 rounded px-3 py-2 border border-zinc-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] text-zinc-300">{label}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-mono text-zinc-600">{secretKey}</span>
|
||||
{isSet && (
|
||||
<span className="text-[9px] font-mono text-zinc-500 tracking-widest" title="Value is set (encrypted)">
|
||||
•••••
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isSet && <span className="text-[8px] text-green-500 bg-green-900/30 px-1.5 py-0.5 rounded">Set</span>}
|
||||
{scope && <ScopeBadge scope={scope} />}
|
||||
{!editing && isSet && (globalMode || scope !== "global") && (
|
||||
<button onClick={onDelete} className="text-[9px] text-red-400 hover:text-red-300">Remove</button>
|
||||
)}
|
||||
<button onClick={() => setEditing(!editing)} className="text-[9px] text-blue-400 hover:text-blue-300">
|
||||
{actionLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={value} onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={isPlaintext ? "anthropic:claude-sonnet-4-6" : "sk-..."}
|
||||
type={isPlaintext ? "text" : "password"} autoFocus
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
|
||||
disabled={!value}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30"
|
||||
>Save</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
|
||||
secretKey: string;
|
||||
scope: "global" | "workspace" | "override";
|
||||
globalMode?: boolean;
|
||||
onSave: (value: string) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const canDelete = globalMode || scope !== "global";
|
||||
const showOverride = !globalMode && scope === "global";
|
||||
|
||||
return (
|
||||
<div className="py-1.5 px-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<span className={`text-[10px] font-mono ${globalMode ? "text-amber-400" : scope === "global" ? "text-zinc-400" : "text-blue-400"}`}>
|
||||
{secretKey}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-zinc-500 tracking-widest ml-2">•••••</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[8px] text-green-500">Set</span>
|
||||
{!globalMode && <ScopeBadge scope={scope} />}
|
||||
{canDelete && !editing && (
|
||||
<button onClick={onDelete} className="text-[9px] text-red-400 hover:text-red-300">Remove</button>
|
||||
)}
|
||||
{(canDelete || showOverride) && (
|
||||
<button onClick={() => setEditing(!editing)} className="text-[9px] text-blue-400 hover:text-blue-300">
|
||||
{editing ? "Cancel" : showOverride ? "Override" : "Update"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<input
|
||||
value={value} onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="New value" type="password" autoFocus
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
|
||||
disabled={!value}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30"
|
||||
>Save</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecretsSection({ workspaceId }: { workspaceId: string }) {
|
||||
const [mergedSecrets, setMergedSecrets] = useState<SecretEntry[]>([]);
|
||||
const [globalSecrets, setGlobalSecrets] = useState<SecretEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [globalMode, setGlobalMode] = useState(false);
|
||||
|
||||
const loadSecrets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [merged, global] = await Promise.all([
|
||||
api.get<SecretEntry[]>(`/workspaces/${workspaceId}/secrets`).catch(() => []),
|
||||
api.get<SecretEntry[]>("/settings/secrets").catch(() => []),
|
||||
]);
|
||||
setMergedSecrets(merged);
|
||||
setGlobalSecrets(global);
|
||||
} catch {
|
||||
setMergedSecrets([]);
|
||||
setGlobalSecrets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => { loadSecrets(); }, [loadSecrets]);
|
||||
|
||||
const handleSave = async (key: string, value: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
if (globalMode) {
|
||||
await api.put("/settings/secrets", { key, value });
|
||||
markAllWorkspacesNeedRestart();
|
||||
} else {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, { key, value });
|
||||
}
|
||||
setNewKey(""); setNewValue(""); setShowAdd(false);
|
||||
loadSecrets();
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "Failed to save"); }
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
if (globalMode) {
|
||||
await api.del(`/settings/secrets/${encodeURIComponent(key)}`);
|
||||
markAllWorkspacesNeedRestart();
|
||||
} else {
|
||||
await api.del(`/workspaces/${workspaceId}/secrets/${encodeURIComponent(key)}`);
|
||||
}
|
||||
loadSecrets();
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "Failed to delete"); }
|
||||
};
|
||||
|
||||
// Build lookup sets from the merged view
|
||||
const mergedByKey = new Map(mergedSecrets.map((s) => [s.key, s]));
|
||||
const globalKeys = new Set(globalSecrets.map((s) => s.key));
|
||||
|
||||
/** Determine scope badge for the workspace (non-global) view */
|
||||
const getScope = (entry: SecretEntry): "global" | "workspace" | "override" => {
|
||||
if (entry.scope === "workspace" && globalKeys.has(entry.key)) return "override";
|
||||
if (entry.scope === "global") return "global";
|
||||
return "workspace";
|
||||
};
|
||||
|
||||
// For workspace view: use merged secrets from the backend (includes inherited globals)
|
||||
// For global view: use global secrets only
|
||||
const activeSecrets = globalMode ? globalSecrets : mergedSecrets;
|
||||
|
||||
// Split into common keys and custom keys
|
||||
const commonKeySet = new Set(COMMON_KEYS.map((c) => c.key));
|
||||
const customSecrets = activeSecrets.filter((s) => !commonKeySet.has(s.key));
|
||||
|
||||
return (
|
||||
<Section title="Secrets & API Keys" defaultOpen={false}>
|
||||
{loading ? (
|
||||
<div className="text-[10px] text-zinc-500">Loading secrets...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-red-400">{error}</div>}
|
||||
|
||||
{/* Scope toggle */}
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<button
|
||||
onClick={() => setGlobalMode(false)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
|
||||
!globalMode ? "bg-blue-600/20 text-blue-300 border border-blue-500/30" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
This Workspace
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGlobalMode(true)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
|
||||
globalMode ? "bg-amber-600/20 text-amber-300 border border-amber-500/30" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
Global (All Workspaces)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{globalMode && (
|
||||
<div className="px-2 py-1.5 bg-amber-950/20 border border-amber-800/30 rounded text-[10px] text-amber-400/80 leading-relaxed">
|
||||
Global keys apply to all workspaces. Workspace-level keys override globals with the same name.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Common keys */}
|
||||
{COMMON_KEYS.map(({ key, label }) => {
|
||||
const entry = globalMode
|
||||
? globalSecrets.find((s) => s.key === key)
|
||||
: mergedByKey.get(key);
|
||||
const isSet = !!entry?.has_value;
|
||||
const scope = globalMode ? undefined : (entry ? getScope(entry) : undefined);
|
||||
return (
|
||||
<SecretRow key={key} label={label} secretKey={key}
|
||||
isSet={isSet}
|
||||
scope={scope}
|
||||
globalMode={globalMode}
|
||||
onSave={(v) => handleSave(key, v)} onDelete={() => handleDelete(key)} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom secrets */}
|
||||
{customSecrets.map((s) => {
|
||||
const scope = globalMode ? ("global" as const) : getScope(s);
|
||||
return (
|
||||
<CustomSecretRow key={s.key} secretKey={s.key}
|
||||
scope={scope}
|
||||
globalMode={globalMode}
|
||||
onSave={(v) => handleSave(s.key, v)} onDelete={() => handleDelete(s.key)} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add new */}
|
||||
{showAdd ? (
|
||||
<div className="bg-zinc-800/50 rounded p-2 space-y-1.5 border border-zinc-700/50">
|
||||
<input value={newKey} onChange={(e) => setNewKey(e.target.value.toUpperCase())} placeholder="KEY_NAME"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] font-mono text-zinc-100 focus:outline-none focus:border-blue-500" />
|
||||
<input value={newValue} onChange={(e) => setNewValue(e.target.value)} placeholder="Value" type="password"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-[10px] text-zinc-100 focus:outline-none focus:border-blue-500" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-30">
|
||||
Save{globalMode ? " (Global)" : ""}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setNewKey(""); setNewValue(""); }}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="text-[10px] text-blue-400 hover:text-blue-300">
|
||||
+ Add {globalMode ? "Global " : ""}Variable
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-[9px] text-zinc-600 pt-1">
|
||||
Values are encrypted and never exposed to the browser.
|
||||
{globalMode
|
||||
? " Global keys are shared across all workspaces. Restart workspaces to apply changes."
|
||||
: " Global keys are inherited; workspace keys override globals with the same name."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
128
canvas/src/components/tabs/config/yaml-utils.ts
Normal file
128
canvas/src/components/tabs/config/yaml-utils.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { ConfigData } from "./form-inputs";
|
||||
|
||||
// Simple YAML parser for config.yaml — handles flat keys, 1-level objects,
|
||||
// lists, and 2-level nesting (e.g., env.required: [...]).
|
||||
export function parseYaml(text: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const lines = text.split("\n");
|
||||
|
||||
function parseValue(v: string): unknown {
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
if (/^\d+$/.test(v)) return parseInt(v, 10);
|
||||
return v;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip blanks and comments
|
||||
if (line.trim() === "" || line.trim().startsWith("#")) { i++; continue; }
|
||||
|
||||
// Top-level key
|
||||
const topMatch = line.match(/^(\w[\w_]*):\s*(.*)/);
|
||||
if (!topMatch) { i++; continue; }
|
||||
|
||||
const key = topMatch[1];
|
||||
const val = topMatch[2].trim();
|
||||
i++;
|
||||
|
||||
if (val !== "" && val !== "[]") {
|
||||
result[key] = parseValue(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Peek ahead to determine structure
|
||||
const nextLine = lines[i] || "";
|
||||
if (val === "[]" || (!nextLine.match(/^\s/) || nextLine.trim() === "" || nextLine.trim().startsWith("#"))) {
|
||||
result[key] = val === "[]" ? [] : "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indented content follows — is it a list or object?
|
||||
if (nextLine.match(/^\s+-\s+/)) {
|
||||
// List
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && lines[i].match(/^\s+-\s+/)) {
|
||||
items.push(lines[i].replace(/^\s+-\s+/, "").trim());
|
||||
i++;
|
||||
}
|
||||
result[key] = items;
|
||||
} else if (nextLine.match(/^\s+\w+:/)) {
|
||||
// Object (1 or 2 levels)
|
||||
const obj: Record<string, unknown> = {};
|
||||
while (i < lines.length) {
|
||||
const sub = lines[i];
|
||||
if (sub.trim() === "" || sub.trim().startsWith("#")) { i++; continue; }
|
||||
// 2-space indented key: value
|
||||
const subMatch = sub.match(/^ (\w[\w_]*):\s*(.*)/);
|
||||
if (!subMatch) break;
|
||||
const subKey = subMatch[1];
|
||||
const subVal = subMatch[2].trim();
|
||||
i++;
|
||||
|
||||
if (subVal !== "" && subVal !== "[]") {
|
||||
obj[subKey] = parseValue(subVal);
|
||||
} else {
|
||||
// Check for nested list (2-level: env.required: [...])
|
||||
const subNext = lines[i] || "";
|
||||
if (subNext.match(/^\s{4,}-\s+/)) {
|
||||
const subItems: string[] = [];
|
||||
while (i < lines.length && lines[i].match(/^\s{4,}-\s+/)) {
|
||||
subItems.push(lines[i].replace(/^\s+-\s+/, "").trim());
|
||||
i++;
|
||||
}
|
||||
obj[subKey] = subItems;
|
||||
} else {
|
||||
obj[subKey] = subVal === "[]" ? [] : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
result[key] = obj;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toYaml(config: ConfigData): string {
|
||||
const lines: string[] = [];
|
||||
const simple = (k: string, v: unknown) => {
|
||||
if (v === undefined || v === null || v === "") return;
|
||||
lines.push(`${k}: ${v}`);
|
||||
};
|
||||
const list = (k: string, arr: string[]) => {
|
||||
if (!arr || arr.length === 0) { lines.push(`${k}: []`); return; }
|
||||
lines.push(`${k}:`);
|
||||
arr.forEach((v) => lines.push(` - ${v}`));
|
||||
};
|
||||
const obj = (k: string, o: Record<string, unknown>) => {
|
||||
if (!o) return;
|
||||
lines.push(`${k}:`);
|
||||
Object.entries(o).forEach(([sk, sv]) => {
|
||||
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
|
||||
});
|
||||
};
|
||||
|
||||
simple("name", config.name);
|
||||
simple("description", config.description);
|
||||
simple("version", config.version);
|
||||
simple("tier", config.tier);
|
||||
if (config.runtime) {
|
||||
lines.push("");
|
||||
simple("runtime", config.runtime);
|
||||
if (config.runtime_config && Object.keys(config.runtime_config).length > 0) {
|
||||
obj("runtime_config", config.runtime_config as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
if (config.model) { lines.push(""); simple("model", config.model); }
|
||||
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
|
||||
if (config.shared_context?.length) { lines.push(""); list("shared_context", config.shared_context); }
|
||||
lines.push(""); list("skills", config.skills);
|
||||
if (config.tools?.length) { list("tools", config.tools); }
|
||||
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
|
||||
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
|
||||
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
79
canvas/src/components/ui/KeyValueField.tsx
Normal file
79
canvas/src/components/ui/KeyValueField.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent } from 'react';
|
||||
import { RevealToggle } from './RevealToggle';
|
||||
|
||||
const AUTO_HIDE_MS = 30_000;
|
||||
|
||||
interface KeyValueFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** ARIA label for the input. */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-style input for secret values with reveal toggle.
|
||||
* Auto-trims whitespace on paste. Auto-hides revealed value after 30s.
|
||||
*/
|
||||
export function KeyValueField({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Enter secret value',
|
||||
disabled = false,
|
||||
'aria-label': ariaLabel = 'Secret value',
|
||||
}: KeyValueFieldProps) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Auto-hide after 30s of inactivity when revealed
|
||||
useEffect(() => {
|
||||
if (revealed) {
|
||||
timerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}
|
||||
}, [revealed]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
// Auto-trim whitespace on paste
|
||||
onChange(e.target.value !== e.target.value.trim()
|
||||
? e.target.value.trim()
|
||||
: e.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData('text');
|
||||
const trimmed = pasted.trim();
|
||||
if (trimmed !== pasted) {
|
||||
e.preventDefault();
|
||||
onChange(trimmed);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="key-value-field">
|
||||
<input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<RevealToggle
|
||||
revealed={revealed}
|
||||
onToggle={() => setRevealed((r) => !r)}
|
||||
label={`Toggle reveal secret`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
canvas/src/components/ui/RevealToggle.tsx
Normal file
46
canvas/src/components/ui/RevealToggle.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
interface RevealToggleProps {
|
||||
revealed: boolean;
|
||||
onToggle: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eye / eye-off icon button for toggling secret visibility.
|
||||
* Uses semantic SVG icons — eye-open when hidden, eye-off when revealed.
|
||||
*/
|
||||
export function RevealToggle({
|
||||
revealed,
|
||||
onToggle,
|
||||
label = 'Toggle visibility',
|
||||
}: RevealToggleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
className="reveal-toggle"
|
||||
title={revealed ? 'Hide value' : 'Show value'}
|
||||
>
|
||||
{revealed ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
canvas/src/components/ui/StatusBadge.tsx
Normal file
28
canvas/src/components/ui/StatusBadge.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { SecretStatus } from '@/types/secrets';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: SecretStatus;
|
||||
}
|
||||
|
||||
const CONFIG: Record<SecretStatus, { icon: string; label: string; className: string }> = {
|
||||
verified: { icon: '✓', label: 'Connection status: verified', className: 'status-badge--valid' },
|
||||
invalid: { icon: '✗', label: 'Connection status: invalid', className: 'status-badge--invalid' },
|
||||
unverified: { icon: '○', label: 'Connection status: unverified', className: 'status-badge--unverified' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Status indicator for a secret key.
|
||||
* Per spec: always icon + color (never color-only) for colour-blind users.
|
||||
*/
|
||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const { icon, label, className } = CONFIG[status];
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`status-badge ${className}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
90
canvas/src/components/ui/TestConnectionButton.tsx
Normal file
90
canvas/src/components/ui/TestConnectionButton.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { TestConnectionState, SecretGroup } from '@/types/secrets';
|
||||
import { validateSecret } from '@/lib/api/secrets';
|
||||
|
||||
interface TestConnectionButtonProps {
|
||||
provider: SecretGroup;
|
||||
secretValue: string;
|
||||
onResult?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const LABELS: Record<TestConnectionState, string> = {
|
||||
idle: 'Test connection',
|
||||
testing: 'Testing\u2026',
|
||||
success: 'Connected \u2713',
|
||||
failure: 'Test failed',
|
||||
};
|
||||
|
||||
const RESET_DELAYS: Record<string, number> = {
|
||||
success: 3000,
|
||||
failure: 5000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional test-connection button shown for supported services (GitHub,
|
||||
* Anthropic, OpenRouter). Hidden entirely for custom keys.
|
||||
*
|
||||
* States: idle → testing → success/failure → auto-reset to idle.
|
||||
*/
|
||||
export function TestConnectionButton({
|
||||
provider,
|
||||
secretValue,
|
||||
onResult,
|
||||
}: TestConnectionButtonProps) {
|
||||
const [state, setState] = useState<TestConnectionState>('idle');
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(resetTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setState('testing');
|
||||
setErrorDetail(null);
|
||||
clearTimeout(resetTimerRef.current);
|
||||
try {
|
||||
const result = await validateSecret(provider, secretValue);
|
||||
const nextState = result.valid ? 'success' : 'failure';
|
||||
setState(nextState);
|
||||
if (!result.valid) {
|
||||
setErrorDetail(result.error ?? 'Could not verify key. Check it has the required permissions.');
|
||||
}
|
||||
onResult?.(result.valid);
|
||||
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS[nextState]!);
|
||||
} catch {
|
||||
setState('failure');
|
||||
setErrorDetail('Connection timed out. Service may be down.');
|
||||
onResult?.(false);
|
||||
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS.failure);
|
||||
}
|
||||
}, [provider, secretValue, onResult]);
|
||||
|
||||
return (
|
||||
<div className="test-connection">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={state === 'testing' || !secretValue}
|
||||
className={`test-connection__btn test-connection__btn--${state}`}
|
||||
>
|
||||
{state === 'testing' && <Spinner />}
|
||||
{LABELS[state]}
|
||||
</button>
|
||||
{errorDetail && state === 'failure' && (
|
||||
<p className="test-connection__error" role="alert">
|
||||
{errorDetail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
34
canvas/src/components/ui/ValidationHint.tsx
Normal file
34
canvas/src/components/ui/ValidationHint.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
interface ValidationHintProps {
|
||||
/** null = valid / not yet validated. string = error message. */
|
||||
error: string | null;
|
||||
/** Shown when value is valid and non-empty. */
|
||||
showValid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline validation feedback below a form field.
|
||||
* - Error: red text + ⚠ icon
|
||||
* - Valid: green text + ✓
|
||||
* - Neutral (null, not yet typed): hidden
|
||||
*
|
||||
* Per spec: 12px caption text, placed below the value field.
|
||||
*/
|
||||
export function ValidationHint({ error, showValid = false }: ValidationHintProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<p className="validation-hint validation-hint--error" role="alert">
|
||||
<span aria-hidden="true">⚠</span> {error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (showValid) {
|
||||
return (
|
||||
<p className="validation-hint validation-hint--valid">
|
||||
<span aria-hidden="true">✓</span> Valid format
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user