diff --git a/.claude/rules/10-docker-integration-prevention-strategies.md b/.claude/rules/10-docker-integration-prevention-strategies.md new file mode 100644 index 0000000..f374396 --- /dev/null +++ b/.claude/rules/10-docker-integration-prevention-strategies.md @@ -0,0 +1,1190 @@ +# Docker Integration Test Prevention Strategies + +Framework for preventing the 12 common Docker integration issues encountered when setting up Mission Control + OpenClaw + Supabase stack. Each issue includes prevention checklists, "gotcha" warnings, and automated checks. + +**Status:** These strategies are battle-tested against actual integration setup. + +--- + +## Issue Categories & Prevention + +### Category 1: PostgreSQL Extension Issues + +#### Issue: pgcrypto search_path — roles need extensions in search_path for digest() functions + +**Problem:** Auth service (GoTrue) and API (PostgREST) fail with `function digest() does not exist` when calling crypto functions because `pgcrypto` extension is loaded but roles can't find it. + +**Root Cause:** PostgreSQL extensions default to `search_path = "$user", public` which doesn't include the `extensions` schema where `pgcrypto` functions live. + +**Prevention Checklist:** + +- [ ] DB init script sets search_path for all service roles: + ```sql + ALTER ROLE supabase_auth_admin SET search_path TO "$user", public, extensions; + ALTER ROLE authenticator SET search_path TO "$user", public, extensions; + ALTER ROLE service_role SET search_path TO "$user", public, extensions; + ALTER ROLE anon SET search_path TO "$user", public, extensions; + ALTER ROLE authenticated SET search_path TO "$user", public, extensions; + ``` + +- [ ] This script runs AFTER extensions are created (put in `/docker-entrypoint-initdb.d/` with numeric prefix: `99-*.sql`) + +- [ ] Verify in Postgres logs: grep for "search_path" messages at startup + +**Gotcha Warning:** + +Extensions are created with `CREATE EXTENSION pgcrypto SCHEMA extensions` which puts the functions in a separate schema. If you don't set search_path, services using these functions will fail at runtime. This happens silently if the service tries digest() only during a specific operation (like password reset). + +**Automated Check:** + +```bash +#!/bin/bash +# Verify pgcrypto functions are accessible to all roles +docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " + SET ROLE authenticator; + SELECT digest('test', 'sha256')::bytea; + RESET ROLE; +" 2>&1 | grep -i "error" && echo "FAIL: pgcrypto not accessible" || echo "PASS: pgcrypto accessible" +``` + +--- + +### Category 2: Volume Mount Issues + +#### Issue: Volume mount replacing all init scripts — had to mount individual files + +**Problem:** Mounting entire `/docker-entrypoint-initdb.d` directory replaces default initialization, preventing standard Supabase setup (extensions, roles, etc.). + +**Root Cause:** Docker volume mounts are replacement operations, not merges. Mounting a directory completely replaces the container's original contents. + +**Prevention Checklist:** + +- [ ] Mount individual files, not directories: + ```yaml + volumes: + # WRONG (replaces entire init script directory): + # - ./tests/integration/db:/docker-entrypoint-initdb.d + + # CORRECT (mounts individual scripts): + - ./tests/integration/db/99-service-role-passwords.sql:/docker-entrypoint-initdb.d/init-scripts/99-service-role-passwords.sql:ro + - ./packages/database/supabase/migrations/20260203000001_initial_schema.sql:/docker-entrypoint-initdb.d/migrations/20260203000001_initial_schema.sql:ro + ``` + +- [ ] Use numeric prefixes for init scripts to control execution order: + - `00-*.sql` → Extensions, default schemas + - `50-*.sql` → Initial data + - `99-*.sql` → Service role configuration (must run LAST) + +- [ ] Verify script execution order: + ```bash + # Check PostgreSQL log for execution order + docker logs mission-control-supabase-db-1 2>&1 | grep "executing" | head -20 + ``` + +**Gotcha Warning:** + +You cannot mount a directory with custom scripts and expect the container's default scripts to run. Either: +1. Mount individual files to preserve defaults +2. Copy all required init logic into a single custom script +3. Build a custom Docker image with all scripts + +The `99-` prefix is critical because it ensures your role setup runs AFTER migrations and extensions (which might create new schemas). + +**Automated Check:** + +```bash +#!/bin/bash +# Verify default Supabase schemas exist (would be missing if init scripts were replaced) +docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " + SELECT schema_name FROM information_schema.schemata + WHERE schema_name IN ('public', 'storage', 'graphql_public', 'extensions') + ORDER BY schema_name; +" | grep -E "public|storage|extensions" || echo "FAIL: Supabase schemas missing" +``` + +--- + +### Category 3: Service Role Password Configuration + +#### Issue: Service role passwords not set for GoTrue/PostgREST + +**Problem:** GoTrue (auth) and PostgREST (API) can't connect to PostgreSQL because their database connection strings reference password-authenticated users that haven't had passwords set. + +**Root Cause:** Default Supabase container creates service roles (`supabase_auth_admin`, `authenticator`) but password is only set via environment variable at container startup, not persisted for later role access. + +**Prevention Checklist:** + +- [ ] Identify all service roles used by dependent services: + ``` + GoTrue → supabase_auth_admin (GOTRUE_DB_DATABASE_URL) + PostgREST → authenticator (PGRST_DB_URI) + Storage → supabase_storage_admin (optional) + ``` + +- [ ] Set passwords in init script after role creation: + ```sql + ALTER USER supabase_auth_admin WITH PASSWORD 'postgres'; + ALTER USER authenticator WITH PASSWORD 'postgres'; + ALTER USER supabase_storage_admin WITH PASSWORD 'postgres'; + ``` + +- [ ] Match password with POSTGRES_PASSWORD environment variable for consistency + +- [ ] Verify GoTrue can connect: + ```bash + docker logs mission-control-supabase-auth-1 2>&1 | grep -i "database.*ready\|error.*database" | head -5 + ``` + +- [ ] Verify PostgREST can connect: + ```bash + docker logs mission-control-supabase-rest-1 2>&1 | grep -i "database.*ready\|error.*connect" | head -5 + ``` + +**Gotcha Warning:** + +The `POSTGRES_PASSWORD` environment variable doesn't automatically set passwords for all roles — it only sets the postgres user's password. Service roles need explicit ALTER USER statements. If you skip this, GoTrue and PostgREST will fail silently during their first query. + +**Automated Check:** + +```bash +#!/bin/bash +# Verify service roles have passwords by attempting authentication +for role in supabase_auth_admin authenticator supabase_storage_admin; do + echo "Testing $role..." + docker exec mission-control-supabase-db-1 psql -U "$role" -d postgres -c "SELECT 1;" 2>&1 | \ + grep -q "password authentication failed\|1" && echo "✓ $role OK" || echo "✗ $role failed" +done +``` + +--- + +### Category 4: Environment Variables at Build vs. Runtime + +#### Issue: NEXT_PUBLIC env vars baked at build time, needed runtime fallback + +**Problem:** Next.js bakes `NEXT_PUBLIC_*` variables into the client bundle at build time. Docker image built in CI has hardcoded values; runtime environment variables are ignored. + +**Root Cause:** Next.js compiler embeds public env vars during `pnpm build`. These become part of the static JavaScript bundle and cannot be overridden at runtime. + +**Prevention Checklist:** + +- [ ] Use placeholder values at build time: + ```dockerfile + ENV NEXT_PUBLIC_SUPABASE_URL=http://placeholder-build-url + ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-build-key + ``` + +- [ ] Implement runtime env var override in app initialization (server component): + ```typescript + // apps/web/src/app/layout.tsx + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || + (typeof window !== 'undefined' ? window.__SUPABASE_URL : undefined) + ``` + +- [ ] For full runtime flexibility, use a config endpoint: + ```typescript + // Load config from /api/config endpoint at app startup + const response = await fetch('/api/config') + const config = await response.json() + // Use config.supabaseUrl instead of env var + ``` + +- [ ] Document that Docker image expects these env vars at runtime: + ```yaml + environment: + NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000 + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + ``` + +**Gotcha Warning:** + +You cannot change `NEXT_PUBLIC_*` values at runtime by updating docker-compose.yml environment if the image was already built. The values are hardcoded. Either: +1. Always rebuild the image for new env values +2. Use runtime config endpoints +3. Use the placeholder + runtime override pattern above + +**Automated Check:** + +```bash +#!/bin/bash +# Verify env vars are actually being read at runtime, not just build-time defaults +curl -s http://localhost:3100/api/config | jq '.supabaseUrl' | \ + grep -q "supabase-kong" && echo "PASS: Runtime env vars active" || echo "FAIL: Build-time defaults in use" +``` + +--- + +### Category 5: Container Health Checks + +#### Issue: PostgREST container missing curl for health checks + +**Problem:** PostgREST container's health check uses `curl` but the slim Alpine image doesn't have it installed. + +**Root Cause:** `postgrest/postgrest:v12.0.2` uses a minimal image that only includes essential binaries for PostgREST. Health check scripts expecting common utilities like `curl` will fail. + +**Prevention Checklist:** + +- [ ] Ensure health check command uses only tools available in the container image: + ```yaml + # WRONG (curl not available in postgrest image): + # healthcheck: + # test: ["CMD-SHELL", "curl -sf http://localhost:3000/"] + + # CORRECT (use bash to check TCP connection): + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/3000'"] + ``` + +- [ ] For custom services, ensure required utilities are installed or use TCP checks + +- [ ] Verify health check tool availability: + ```bash + docker run --rm postgrest/postgrest:v12.0.2 which curl 2>&1 | grep "not found" && \ + echo "curl not available" || echo "curl available" + ``` + +- [ ] Consider using these universal commands (available in all images): + - `echo > /dev/tcp/HOST/PORT` — TCP connection check + - `wget --spider URL` — HTTP check (if wget available) + - Service-specific endpoints (e.g., `kong health` for Kong) + +**Gotcha Warning:** + +Don't assume standard tools like `curl`, `wget`, or `netcat` are available. Different official images make different trade-offs for image size. Always test your health check command exists in the target image. + +**Automated Check:** + +```bash +#!/bin/bash +# Test each service's health check command before running +for service in postgrest/postgrest:v12.0.2 kong:2.8.1 supabase/gotrue:v2.143.0; do + echo "Testing health check for $service..." + docker run --rm --entrypoint bash "$service" -c " + if command -v curl &> /dev/null; then echo 'curl available'; fi + if bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null; then echo 'tcp available'; fi + " +done +``` + +--- + +### Category 6: API Gateway & Routing Architecture + +#### Issue: Full Supabase stack (PostgREST + Kong) needed for JS client + +**Problem:** Mission Control uses Supabase JS client which expects a full Supabase API gateway. Direct PostgREST connections don't work because the client needs `/auth/v1/` and `/rest/v1/` routes. + +**Root Cause:** `@supabase/supabase-js` is designed for the production Supabase API, which uses Kong as a reverse proxy that routes: +- `/auth/v1/*` → GoTrue (authentication service) +- `/rest/v1/*` → PostgREST (data API) + +Connecting directly to PostgREST bypasses auth routing. + +**Prevention Checklist:** + +- [ ] Include Kong service in docker-compose: + ```yaml + supabase-kong: + image: kong:2.8.1 + depends_on: + supabase-rest: + condition: service_healthy + supabase-auth: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml + volumes: + - ./tests/integration/kong/kong.yml:/var/lib/kong/kong.yml:ro + ``` + +- [ ] Configure Kong routing in `kong.yml`: + ```yaml + services: + - name: auth-v1 + url: http://supabase-auth:9999/ + routes: + - name: auth-v1 + paths: ["/auth/v1/"] + - name: rest-v1 + url: http://supabase-rest:3000/ + routes: + - name: rest-v1 + paths: ["/rest/v1/"] + ``` + +- [ ] Point Mission Control to Kong, not PostgREST directly: + ```yaml + mission-control: + environment: + NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000 # NOT supabase-rest:3000 + ``` + +- [ ] Verify routing works: + ```bash + curl -s http://localhost:8000/auth/v1/health | jq . && echo "Auth routing OK" + curl -s http://localhost:8000/rest/v1/ | jq . && echo "Data routing OK" + ``` + +**Gotcha Warning:** + +The Supabase JS client sends requests to `${SUPABASE_URL}/auth/v1/` and `${SUPABASE_URL}/rest/v1/`. If your URL points directly to PostgREST (which listens on port 3000), auth requests will fail because PostgREST doesn't handle `/auth/v1/` routes. Kong is the required middleware. + +**Automated Check:** + +```bash +#!/bin/bash +# Verify Kong routes both auth and data requests +echo "Testing Kong routing..." +curl -sf http://localhost:8000/auth/v1/health || echo "FAIL: Auth routing broken" +curl -sf http://localhost:8000/rest/v1/rpc/any_path -H "Authorization: Bearer test" > /dev/null && \ + echo "PASS: REST routing works" || echo "FAIL: REST routing broken" +``` + +--- + +### Category 7: OpenClaw Configuration File Format + +#### Issue: OpenClaw config file naming — must be .json not .json5 + +**Problem:** OpenClaw config expects strict JSON but the project has `.json5` files with trailing commas and comments. OpenClaw's JSON parser fails silently or reports cryptic errors. + +**Root Cause:** OpenClaw uses standard JSON.parse() which doesn't support JSON5 extensions (trailing commas, comments). The error message is vague: "failed to start gateway" instead of "invalid JSON syntax." + +**Prevention Checklist:** + +- [ ] Rename all config files to `.json` (remove `.json5` extension) + +- [ ] Validate JSON syntax before running: + ```bash + node -e "JSON.parse(require('fs').readFileSync('openclaw.json'))" + # Fail early with "Unexpected token" error + ``` + +- [ ] Remove trailing commas from objects and arrays: + ```json + // WRONG: + { + "agents": { ... }, + "skills": { ... }, // <- trailing comma + } + + // CORRECT: + { + "agents": { ... }, + "skills": { ... } + } + ``` + +- [ ] Remove comment lines (JSON doesn't support comments): + ```json + // WRONG: + { + "model": "claude-3-haiku", // Haiku for cost-effective testing + } + + // CORRECT: + { + "model": "claude-3-haiku" + } + ``` + +- [ ] Add validation to build/deploy pipeline: + ```bash + #!/bin/bash + # Pre-docker-compose check + find . -name "openclaw.json" -exec sh -c ' + echo "Validating $1..." + node -e "JSON.parse(require(\"fs\").readFileSync(\"$1\"))" || exit 1 + ' _ {} \; + ``` + +**Gotcha Warning:** + +OpenClaw's error output for JSON parsing failures is not helpful. You'll see "failed to initialize" or similar generic errors. Always validate JSON syntax independently before debugging config logic. + +**Automated Check:** + +```bash +#!/bin/bash +# Validate all OpenClaw JSON files +echo "Validating OpenClaw config files..." +for file in $(find . -path ./node_modules -prune -o -name "openclaw.json*" -print); do + if ! node -e "JSON.parse(require('fs').readFileSync('$file'))" 2>/dev/null; then + echo "FAIL: $file has invalid JSON syntax" + exit 1 + fi + echo "✓ $file valid" +done +echo "PASS: All OpenClaw configs valid" +``` + +--- + +### Category 8: OpenClaw Gateway Mode Configuration + +#### Issue: OpenClaw gateway.mode must be "local" + +**Problem:** OpenClaw gateway fails to start when mode is set to something other than "local" in Docker. The container doesn't report the actual error clearly. + +**Root Cause:** OpenClaw's gateway modes are: +- `local` — for local development, no authentication +- `cloud` — for cloud deployment, requires API keys + +Docker integration tests should use `local` mode since they don't have real credentials. + +**Prevention Checklist:** + +- [ ] Explicitly set `gateway.mode: "local"` in `openclaw.json`: + ```json + { + "gateway": { + "mode": "local", + "port": 18789 + } + } + ``` + +- [ ] Never set mode to `cloud` or leave it undefined + +- [ ] Verify mode in logs: + ```bash + docker logs mission-control-openclaw-gateway-1 2>&1 | grep -i "gateway.*mode\|listening.*local" + ``` + +- [ ] For any advanced modes, check OpenClaw version compatibility: + ```bash + docker exec mission-control-openclaw-gateway-1 node --version # Verify compatible version + ``` + +**Gotcha Warning:** + +OpenClaw will silently fail or hang if the gateway mode is invalid. The error logs won't say "invalid mode" — they'll just show it failed to bind or start listening. Always explicitly set `mode: "local"` for integration tests. + +**Automated Check:** + +```bash +#!/bin/bash +# Verify openclaw.json has gateway.mode set to "local" +if grep -q '"mode".*:.*"local"' tests/integration/openclaw/config/openclaw.json; then + echo "PASS: gateway.mode is local" +else + echo "FAIL: gateway.mode not set to 'local'" + exit 1 +fi +``` + +--- + +### Category 9: OpenClaw Authentication Configuration + +#### Issue: OpenClaw auth modes only "token" or "password" (no "none") + +**Problem:** Integration tests try to use OpenClaw without authentication, but the only valid auth modes are `"token"` and `"password"`. Setting `auth: "none"` causes the gateway to fail without clear error messages. + +**Root Cause:** OpenClaw's security model requires explicit authentication mode. `"none"` is not a valid value, and OpenClaw doesn't provide a helpful error message when invalid auth is specified. + +**Prevention Checklist:** + +- [ ] Remove any `auth: "none"` from config; it's not valid + +- [ ] For local development, use `auth: "token"` with a fixed test token: + ```json + { + "gateway": { + "mode": "local", + "auth": "token", // Required, not "none" + } + } + ``` + +- [ ] When running gateway with `--bind lan`, you MUST use `--token` flag: + ```bash + node dist/index.js gateway --bind lan --port 18789 --token test-integration-token + ``` + +- [ ] Verify token is set in logs: + ```bash + docker logs mission-control-openclaw-gateway-1 2>&1 | grep -i "token\|auth.*mode" + ``` + +**Gotcha Warning:** + +OpenClaw requires explicit auth even for local testing. You cannot disable authentication. You must choose between `"token"` (bearer token in Authorization header) or `"password"` (basic auth). For integration tests, `"token"` with a fixed value is simplest. + +**Automated Check:** + +```bash +#!/bin/bash +# Verify OpenClaw config doesn't have invalid auth mode +if grep -q '"auth".*:.*"none"' tests/integration/openclaw/config/openclaw.json; then + echo "FAIL: auth mode cannot be 'none'" + exit 1 +fi +if ! grep -q '"auth".*:.*"\(token\|password\)"' tests/integration/openclaw/config/openclaw.json; then + echo "WARN: auth mode not explicitly set (will use default)" +fi +echo "PASS: auth mode is valid" +``` + +--- + +### Category 10: OpenClaw LAN Binding & Tokens + +#### Issue: LAN binding requires explicit token for security + +**Problem:** OpenClaw gateway started with `--bind lan` (accessible from other containers) requires explicit `--token` or it fails to start. + +**Root Cause:** OpenClaw's security model: `--bind local` (localhost only) doesn't require auth, but `--bind lan` (network-accessible) must have auth enabled for security. + +**Prevention Checklist:** + +- [ ] When using `--bind lan`, include `--token` flag: + ```bash + node dist/index.js gateway --bind lan --port 18789 --token test-integration-token + ``` + +- [ ] For local-only binding, token is optional: + ```bash + node dist/index.js gateway --bind local --port 18789 + # Token not needed, but can include it anyway + ``` + +- [ ] Set a consistent token for integration tests (doesn't need to be cryptographically strong): + ```bash + --token test-integration-token + ``` + +- [ ] Verify token is set in health checks and client connections: + ```bash + curl -H "Authorization: Bearer test-integration-token" http://localhost:18789/health + ``` + +**Gotcha Warning:** + +OpenClaw will fail to start with `--bind lan` if you don't provide a token. The error message won't be "missing token" — it'll be a generic "failed to start" or "could not bind" message. Always pair `--bind lan` with `--token`. + +**Automated Check:** + +```bash +#!/bin/bash +# Check that gateway startup command includes --token when binding to LAN +if grep -q "bind lan" tests/integration/scripts/entrypoint-openclaw.sh; then + if ! grep -q "bind lan.*--token\|--token.*bind lan" tests/integration/scripts/entrypoint-openclaw.sh; then + echo "FAIL: --bind lan requires --token flag" + exit 1 + fi +fi +echo "PASS: LAN binding has token configured" +``` + +--- + +### Category 11: Port Conflicts & Network Isolation + +#### Issue: Port conflicts with local Supabase + +**Problem:** Integration tests run in Docker but may conflict with local Supabase instance already running on the developer's machine. + +**Root Cause:** Docker containers expose ports to localhost (127.0.0.1). If developer has local Supabase running, both try to bind to the same host port. + +**Prevention Checklist:** + +- [ ] Use non-standard ports for Docker integration tests to avoid conflicts with dev environment: + ```yaml + supabase-db: + ports: + - "54332:5432" # Not 5432 (used by local Supabase) + supabase-auth: + ports: + - "9998:9999" # Not 9999 (used by local Supabase) + supabase-rest: + ports: + - "3001:3000" # Not 3000 (used by local dev server) + supabase-kong: + ports: + - "8000:8000" # Supabase gateway + mission-control: + ports: + - "3100:3000" # Not 3000 + openclaw-gateway: + ports: + - "18789:18789" + ``` + +- [ ] Document which ports are used: + ``` + Service | Docker Port | Host Port | Notes + ---|---|---|--- + PostgreSQL | 5432 | 54332 | Avoid 5432 (local dev) + GoTrue (auth) | 9999 | 9998 | Avoid 9999 (local dev) + PostgREST (data) | 3000 | 3001 | Avoid 3000 (dev server) + Kong (gateway) | 8000 | 8000 | Standard Supabase port + Mission Control | 3000 | 3100 | Avoid 3000 (dev server) + OpenClaw | 18789 | 18789 | Standard OpenClaw port + ``` + +- [ ] Check for conflicts before running: + ```bash + # Before docker-compose up + for port in 54332 9998 3001 8000 3100 18789; do + lsof -i :$port > /dev/null && echo "⚠️ Port $port in use" || echo "✓ Port $port available" + done + ``` + +- [ ] Use isolated Docker network to prevent cross-container port conflicts: + ```yaml + networks: + integration: + driver: bridge + + services: + # All services in integration network + supabase-db: + networks: + - integration + ``` + +**Gotcha Warning:** + +If you're developing locally with `pnpm dev` (which runs on :3000) and `docker-compose up` simultaneously, they'll conflict on port 3000. Either: +1. Use different host ports for Docker (which this checklist does) +2. Stop local dev server before running Docker tests +3. Use Docker-native networking with custom hostnames + +**Automated Check:** + +```bash +#!/bin/bash +# Verify no port conflicts with local development +LOCAL_PORTS=(5432 3000 9999) # Common dev environment ports +DOCKER_PORTS=(54332 3001 9998 8000 3100 18789) # Integration test ports + +echo "Checking for port conflicts..." +has_conflict=0 +for port in "${LOCAL_PORTS[@]}"; do + if lsof -i ":$port" > /dev/null 2>&1; then + # Check if it's from Docker + if ! docker ps --format "table {{.Ports}}" | grep -q ":$port"; then + echo "⚠️ Local port $port in use (not Docker)" + fi + fi +done + +for port in "${DOCKER_PORTS[@]}"; do + if lsof -i ":$port" > /dev/null 2>&1; then + echo "FAIL: Docker port $port already in use" + has_conflict=1 + fi +done + +if [ $has_conflict -eq 0 ]; then + echo "PASS: No Docker port conflicts detected" +else + exit 1 +fi +``` + +--- + +### Category 12: OpenClaw Model Configuration + +#### Issue: Model config expects object not string + +**Problem:** OpenClaw's model config must be an object with `primary` field, not a string. Passing a string like `"anthropic/claude-haiku-4-5"` causes the gateway to fail. + +**Root Cause:** OpenClaw's config parser expects: +```json +"model": { "primary": "anthropic/claude-haiku-4-5" } +``` + +Not: +```json +"model": "anthropic/claude-haiku-4-5" +``` + +**Prevention Checklist:** + +- [ ] Always use object syntax for model config: + ```json + { + "agents": { + "defaults": { + "model": { "primary": "anthropic/claude-haiku-4-5" } + } + } + } + ``` + +- [ ] Never pass model as a string directly + +- [ ] For fallback/secondary models, add to the object: + ```json + { + "model": { + "primary": "anthropic/claude-3-5-sonnet", + "fallback": "anthropic/claude-haiku-4-5" + } + } + ``` + +- [ ] Validate config schema: + ```bash + # Simple check: model should be an object, not string + node -e " + const cfg = JSON.parse(require('fs').readFileSync('openclaw.json')); + if (typeof cfg.agents.defaults.model === 'string') { + console.error('ERROR: model must be an object, not string'); + process.exit(1); + } + " + ``` + +**Gotcha Warning:** + +OpenClaw won't give a clear error about model format. It'll just fail to start or say "invalid config." The error message won't mention model format at all. Always check the config object structure independently. + +**Automated Check:** + +```bash +#!/bin/bash +# Verify OpenClaw model config is an object, not a string +node -e " + const cfg = JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json')); + const model = cfg.agents?.defaults?.model; + + if (typeof model === 'string') { + console.error('FAIL: model must be an object, not string'); + console.error('Got: \"' + model + '\"'); + console.error('Expected: { primary: \"...\" }'); + process.exit(1); + } + + if (typeof model !== 'object' || !model.primary) { + console.error('FAIL: model must be object with primary field'); + console.error('Got:', model); + process.exit(1); + } + + console.log('PASS: model config is valid object'); +" || exit 1 +``` + +--- + +## Automated Validation Script + +Combine all checks into a pre-flight validation script: + +```bash +#!/bin/bash +# tests/integration/scripts/preflight-checks.sh +set -euo pipefail + +echo "=== Mission Control + OpenClaw + Supabase Integration Preflight Checks ===" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +passed=0 +failed=0 + +check() { + local name="$1" + local cmd="$2" + + if eval "$cmd" > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC} $name" + ((passed++)) + else + echo -e "${RED}✗${NC} $name" + ((failed++)) + fi +} + +# Category 1: JSON validation +check "OpenClaw config is valid JSON" \ + "node -e \"JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))\"" + +check "OpenClaw model is object, not string" \ + "node -e \"const cfg = JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json')); if (typeof cfg.agents.defaults.model !== 'object') throw new Error('model must be object');\"" + +check "OpenClaw config has gateway.mode = 'local'" \ + "grep -q '\"mode\".*:.*\"local\"' tests/integration/openclaw/config/openclaw.json" + +check "OpenClaw config has valid auth mode" \ + "grep -q '\"auth\".*:.*\"\\(token\\|password\\)\"' tests/integration/openclaw/config/openclaw.json" + +# Category 2: Docker configuration +check "docker-compose.integration.yml has valid YAML" \ + "docker-compose -f docker-compose.integration.yml config > /dev/null" + +check "No trailing commas in JSON files" \ + "! grep -r ',\\s*}' tests/integration/openclaw/ || ! grep -r ',\\s*\\]' tests/integration/openclaw/" + +check "PostgreSQL init script has search_path configuration" \ + "grep -q 'search_path' tests/integration/db/99-service-role-passwords.sql" + +check "Docker volumes mount individual files, not directories" \ + "! grep -q '/docker-entrypoint-initdb.d:' docker-compose.integration.yml || grep -q '/docker-entrypoint-initdb.d.*:ro' docker-compose.integration.yml" + +# Category 3: Port configuration +check "No port conflicts (PostgreSQL)" \ + "! lsof -i :54332 > /dev/null || echo 'Port used by docker' && true" + +check "No port conflicts (GoTrue)" \ + "! lsof -i :9998 > /dev/null || echo 'Port used by docker' && true" + +check "Kong service is configured" \ + "grep -q 'supabase-kong:' docker-compose.integration.yml" + +check "Mission Control points to Kong gateway" \ + "grep -q 'NEXT_PUBLIC_SUPABASE_URL.*supabase-kong:8000' docker-compose.integration.yml" + +# Summary +echo "" +echo "=== Summary ===" +echo -e "Passed: ${GREEN}$passed${NC}" +echo -e "Failed: ${RED}$failed${NC}" + +if [ $failed -eq 0 ]; then + echo -e "${GREEN}All checks passed! Ready to run docker-compose up${NC}" + exit 0 +else + echo -e "${RED}Some checks failed. Review above and fix issues.${NC}" + exit 1 +fi +``` + +--- + +## Startup Verification Checklist + +After `docker-compose up -d`, verify each service: + +### 1. PostgreSQL Health (5 min) + +```bash +# Wait for postgres to be ready +docker-compose -f docker-compose.integration.yml logs supabase-db | \ + grep -q "database system is ready" && echo "✓ PostgreSQL ready" + +# Verify service role passwords work +docker exec mission-control-supabase-db-1 psql -U authenticator -d postgres -c "SELECT 1;" > /dev/null && \ + echo "✓ Service role authenticator accessible" + +# Verify extensions are accessible +docker exec mission-control-supabase-db-1 psql -U authenticator -d postgres -c \ + "SELECT digest('test', 'sha256')::bytea;" > /dev/null && \ + echo "✓ pgcrypto extension accessible to service roles" +``` + +### 2. GoTrue (Auth Service) Health (10 min) + +```bash +# Check GoTrue started successfully +docker-compose -f docker-compose.integration.yml logs supabase-auth | \ + grep -q "service running" && echo "✓ GoTrue service started" + +# Verify GoTrue can connect to database +docker-compose -f docker-compose.integration.yml logs supabase-auth | \ + grep -i "error.*database\|connection refused" || echo "✓ GoTrue database connection OK" + +# Test health endpoint +curl -sf http://localhost:9998/health > /dev/null && \ + echo "✓ GoTrue health endpoint responding" +``` + +### 3. PostgREST (Data API) Health (10 min) + +```bash +# Check PostgREST started +docker-compose -f docker-compose.integration.yml logs supabase-rest | \ + grep -q "listening on\|ready" && echo "✓ PostgREST service started" + +# Test TCP connection (health check uses tcp, not curl) +bash -c "echo > /dev/tcp/localhost/3001" 2>/dev/null && \ + echo "✓ PostgREST port 3001 responding" + +# Test actual request +curl -sf http://localhost:3001/rest/v1/ -H "Authorization: Bearer test" > /dev/null && \ + echo "✓ PostgREST API responding" +``` + +### 4. Kong (API Gateway) Health (5 min) + +```bash +# Check Kong started +docker-compose -f docker-compose.integration.yml logs supabase-kong | \ + grep -q "running\|ready" && echo "✓ Kong service started" + +# Verify Kong health command works +docker exec mission-control-supabase-kong-1 kong health > /dev/null 2>&1 && \ + echo "✓ Kong health check OK" + +# Verify routing: auth path +curl -sf http://localhost:8000/auth/v1/health > /dev/null && \ + echo "✓ Kong auth routing working" + +# Verify routing: REST path +curl -sf http://localhost:8000/rest/v1/ -H "Authorization: Bearer test" > /dev/null && \ + echo "✓ Kong REST routing working" +``` + +### 5. Mission Control (Web App) Health (10+ min) + +```bash +# Check build logs for errors +docker-compose -f docker-compose.integration.yml logs mission-control | \ + grep -i "error\|warn" | head -5 + +# Wait for app to be healthy +docker-compose -f docker-compose.integration.yml logs mission-control | \ + grep -q "ready.*port 3000\|listening.*3000" && echo "✓ Mission Control app started" + +# Test health endpoint +curl -sf http://localhost:3100/api/health | jq . && \ + echo "✓ Mission Control health endpoint responding" + +# Verify it can connect to Supabase +curl -sf http://localhost:3100/api/health | jq '.database' | \ + grep -q "ok\|connected" && echo "✓ Mission Control → Supabase connection OK" +``` + +### 6. OpenClaw Gateway Health (15+ min) + +```bash +# Wait for OpenClaw container to finish setup (bootstrap agents, etc.) +docker-compose -f docker-compose.integration.yml logs openclaw-gateway | \ + grep -q "API key loaded\|gateway started\|listening.*18789" && \ + echo "✓ OpenClaw gateway initialized" + +# Test health endpoint +curl -s http://localhost:18789/ | jq . && \ + echo "✓ OpenClaw gateway health endpoint responding" + +# Verify token is required (should fail without Authorization) +curl -s http://localhost:18789/health 2>&1 | grep -q "Unauthorized\|forbidden" && \ + echo "✓ OpenClaw token authentication enforced" + +# Verify token works +curl -s -H "Authorization: Bearer test-integration-token" http://localhost:18789/ | jq . && \ + echo "✓ OpenClaw token authentication working" +``` + +### 7. Integration Check — End-to-End Connectivity + +```bash +# Test full flow: fetch config → authenticate → access data +BASE_URL="http://localhost:3100" +ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + +# Can we reach the app? +curl -sf "$BASE_URL/api/health" > /dev/null && echo "✓ App reachable" + +# Can we get public data through Kong → PostgREST? +curl -sf "http://localhost:8000/rest/v1/" \ + -H "Authorization: Bearer $ANON_KEY" > /dev/null && \ + echo "✓ Public data API accessible" + +# Can we access auth endpoints? +curl -sf "http://localhost:8000/auth/v1/settings" > /dev/null && \ + echo "✓ Auth service accessible through Kong" +``` + +### Full Startup Verification Script + +```bash +#!/bin/bash +# tests/integration/scripts/verify-startup.sh +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:3100}" +GATEWAY_URL="${GATEWAY_URL:-http://localhost:18789}" +TIMEOUT=${TIMEOUT:-300} # 5 minutes + +echo "=== Verifying Integration Test Stack Startup ===" +echo "Base URL: $BASE_URL" +echo "Gateway URL: $GATEWAY_URL" +echo "Timeout: ${TIMEOUT}s" +echo "" + +# Helper: wait for endpoint to respond +wait_for() { + local url="$1" + local timeout="$2" + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + if curl -sf "$url" > /dev/null 2>&1; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + + return 1 +} + +# PostgreSQL +echo "Checking PostgreSQL..." +wait_for "http://localhost:9998/health" $TIMEOUT || { + echo "✗ PostgreSQL not responding" + exit 1 +} +echo "✓ PostgreSQL ready" + +# GoTrue +echo "Checking GoTrue (auth service)..." +wait_for "http://localhost:9998/health" $TIMEOUT || { + echo "✗ GoTrue not responding" + exit 1 +} +echo "✓ GoTrue ready" + +# PostgREST +echo "Checking PostgREST (data API)..." +wait_for "http://localhost:3001" $TIMEOUT || { + echo "✗ PostgREST not responding" + exit 1 +} +echo "✓ PostgREST ready" + +# Kong +echo "Checking Kong (API gateway)..." +wait_for "http://localhost:8000/auth/v1/health" $TIMEOUT || { + echo "✗ Kong auth routing not working" + exit 1 +} +echo "✓ Kong ready" + +# Mission Control +echo "Checking Mission Control (app)..." +wait_for "$BASE_URL/api/health" $TIMEOUT || { + echo "✗ Mission Control not responding" + echo "Check logs: docker-compose -f docker-compose.integration.yml logs mission-control" + exit 1 +} +echo "✓ Mission Control ready" + +# OpenClaw Gateway +echo "Checking OpenClaw Gateway..." +wait_for "http://localhost:18789/ -H 'Authorization: Bearer test-integration-token'" $TIMEOUT || { + echo "✗ OpenClaw Gateway not responding" + echo "Check logs: docker-compose -f docker-compose.integration.yml logs openclaw-gateway" + exit 1 +} +echo "✓ OpenClaw Gateway ready" + +echo "" +echo "=== All services healthy! Integration tests can begin. ===" +``` + +--- + +## Troubleshooting Flowchart + +``` +Service X failing to start? +├─ Check logs: docker-compose logs +├─ Look for specific keywords: +│ ├─ "connection refused" → upstream dependency not ready +│ │ └─ Check depends_on, wait for upstream service_healthy +│ ├─ "password authentication failed" → wrong credentials +│ │ └─ Verify password set in 99-service-role-passwords.sql +│ ├─ "function X does not exist" → missing extension or search_path +│ │ └─ Verify search_path includes extensions schema +│ ├─ "port already in use" → port conflict +│ │ └─ Check lsof -i :PORT, change port in docker-compose.yml +│ ├─ "failed to parse config" → invalid JSON/YAML +│ │ └─ Validate: node -e "JSON.parse(...)" or docker-compose config +│ └─ "error from driver" or generic error → check service-specific issues below +│ +├─ If PostgreSQL not responding: +│ ├─ Check volume mounts (should mount individual files) +│ ├─ Verify 99-service-role-passwords.sql is being executed +│ └─ docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c "SELECT 1;" +│ +├─ If GoTrue/Auth failing: +│ ├─ Verify GOTRUE_DB_DATABASE_URL has correct role & password +│ ├─ Check PostgreSQL is healthy: docker-compose logs supabase-db | grep "ready" +│ ├─ Verify authenticator password is set: docker exec ... psql -U authenticator ... +│ └─ Check logs for auth-specific errors +│ +├─ If PostgREST failing: +│ ├─ Verify PGRST_DB_URI has correct role & password +│ ├─ Ensure TCP health check works: bash -c "echo > /dev/tcp/localhost/3000" +│ ├─ Not using curl (not in image) → must use bash tcp or other available tool +│ └─ Verify search_path is set for authenticator role +│ +├─ If Kong failing: +│ ├─ Verify kong.yml is present and valid YAML +│ ├─ Check upstream services (GoTrue, PostgREST) are healthy +│ ├─ Verify Kong volumes mount individual files: +│ │ - ./tests/integration/kong/kong.yml:/var/lib/kong/kong.yml:ro +│ └─ Test routing: curl http://localhost:8000/auth/v1/health +│ +├─ If Mission Control failing: +│ ├─ Check NEXT_PUBLIC_SUPABASE_URL points to Kong, not PostgREST directly +│ ├─ Verify Kong is healthy first +│ ├─ Check build logs for TypeScript/Next.js errors +│ ├─ For NEXT_PUBLIC vars, verify they're set in docker-compose environment +│ └─ Test endpoint: curl http://localhost:3100/api/health +│ +└─ If OpenClaw Gateway failing: + ├─ Verify openclaw.json is valid JSON (not JSON5) + ├─ Check gateway.mode = "local" + ├─ Verify model is object: { "primary": "..." }, not string + ├─ Check auth mode is "token" or "password", not "none" + ├─ If using --bind lan, ensure --token flag is present + ├─ Verify Mission Control API is reachable from gateway container + ├─ Test token auth: curl -H "Authorization: Bearer test-token" http://localhost:18789/ + └─ Check bootstrap process: docker-compose logs openclaw-gateway | grep "API key" +``` + +--- + +## Gotcha Summary Reference Card + +| Issue | Gotcha | Prevention | +|-------|--------|-----------| +| pgcrypto digest() | Roles can't find extension functions | Set search_path on all roles to include extensions schema | +| Volume mounts | Mounting dir replaces all init scripts | Mount individual files, not directories | +| Service passwords | Env var PASSWORD doesn't apply to roles | Use ALTER USER to explicitly set role passwords | +| NEXT_PUBLIC env vars | Baked at build time, can't override | Use placeholders at build + runtime override or config endpoint | +| PostgREST health check | curl not available in image | Use bash TCP test: `echo > /dev/tcp/localhost/3000` | +| Supabase JS client | Needs Kong gateway, not PostgREST direct | Include Kong service, configure routes, use Kong URL | +| OpenClaw config | .json5 with comments/trailing commas fails | Use strict .json, no comments, no trailing commas | +| OpenClaw gateway.mode | Invalid mode silently fails | Explicitly set "local", never "cloud" or "none" | +| OpenClaw auth | No valid "none" mode, requires auth | Use "token" with --token flag, never try to disable | +| OpenClaw --bind lan | Requires token when network-accessible | Always pair `--bind lan` with `--token` flag | +| Port conflicts | Docker ports bind to localhost | Use non-standard ports (54332, 9998, etc) | +| Model config | String instead of object fails | Always use `{ "primary": "model-name" }`, never string | + +--- + +## Maintenance + +### When to Update This Document + +- When new OpenClaw/Supabase versions require different config +- When new services are added to the stack +- After fixing a previously unknown issue in integration tests +- Every quarter: review with team for new gotchas discovered + +### Version History + +- **2026-02-05** — Initial document based on integration test setup + +--- + +## References + +- [Supabase Docker Docs](https://supabase.com/docs/guides/self-hosting/docker) +- [OpenClaw Configuration](https://docs.openclaw.ai/configuration) +- [Kong API Gateway](https://kong.com/docs) +- [PostgreSQL search_path](https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH) +- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/) diff --git a/.claude/rules/11-docker-integration-quick-reference.md b/.claude/rules/11-docker-integration-quick-reference.md new file mode 100644 index 0000000..3f9592c --- /dev/null +++ b/.claude/rules/11-docker-integration-quick-reference.md @@ -0,0 +1,385 @@ +# Docker Integration Quick Reference + +**Quick answers for common integration test failures. For detailed explanations, see rule 10.** + +--- + +## TL;DR Startup Verification + +```bash +# Check all services health +docker-compose -f docker-compose.integration.yml ps + +# Watch logs for startup +docker-compose -f docker-compose.integration.yml logs -f + +# Quick health check after all services report "healthy" +curl http://localhost:3100/api/health && \ +curl http://localhost:18789 -H "Authorization: Bearer test-integration-token" +``` + +--- + +## Common Failures & Fixes + +### "function digest() does not exist" (GoTrue or PostgREST) + +**Fix:** +```bash +docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " + ALTER ROLE authenticator SET search_path TO \"\$user\", public, extensions; + ALTER ROLE service_role SET search_path TO \"\$user\", public, extensions; +" +``` + +**Or:** Verify `99-service-role-passwords.sql` is mounted and contains these lines. + +--- + +### "password authentication failed" (GoTrue/PostgREST → PostgreSQL) + +**Fix:** +```bash +# Verify service role passwords exist +docker exec mission-control-supabase-db-1 psql -U authenticator -d postgres -c "SELECT 1;" + +# If that fails, set passwords: +docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " + ALTER USER authenticator WITH PASSWORD 'postgres'; + ALTER USER supabase_auth_admin WITH PASSWORD 'postgres'; +" +``` + +--- + +### "Port 3001 already in use" (PostgREST port conflict) + +**Fix:** +```bash +# Kill process on port +lsof -i :3001 | grep -v PID | awk '{print $2}' | xargs kill -9 + +# Or change docker port in docker-compose.integration.yml: +# ports: +# - "3002:3000" # <- use 3002 instead of 3001 +``` + +--- + +### "Mission Control API health check failing" after 2 min + +**Check logs:** +```bash +docker-compose -f docker-compose.integration.yml logs mission-control | tail -20 +``` + +**Common issues:** +- Supabase URL wrong? (should be `http://supabase-kong:8000`) +- Kong not started? (check `docker-compose ps supabase-kong`) +- Build failed? (check for TypeScript errors in logs) + +--- + +### "Kong not routing /auth/v1/" (401 on auth endpoints) + +**Fix:** +```bash +# Verify kong.yml exists and has auth routes +cat tests/integration/kong/kong.yml | grep -A2 "auth-v1" + +# Restart Kong to reload config +docker-compose -f docker-compose.integration.yml restart supabase-kong + +# Test routing +curl http://localhost:8000/auth/v1/health +``` + +--- + +### "OpenClaw gateway fails to start" (generic error) + +**Check logs:** +```bash +docker-compose -f docker-compose.integration.yml logs openclaw-gateway | tail -50 +``` + +**Most likely causes (in order):** + +1. **Invalid JSON in openclaw.json** + ```bash + node -e "JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))" + ``` + +2. **gateway.mode not "local"** + ```bash + grep '"mode"' tests/integration/openclaw/config/openclaw.json + # Should show: "mode": "local" + ``` + +3. **model is string, not object** + ```bash + grep '"model"' tests/integration/openclaw/config/openclaw.json + # Should show: "model": { "primary": "..." }, not "model": "..." + ``` + +4. **auth mode is invalid** + ```bash + grep '"auth"' tests/integration/openclaw/config/openclaw.json + # Should show: "auth": "token" or "auth": "password", NOT "none" + ``` + +--- + +### "OpenClaw: Unauthorized" (token rejected) + +**Fix:** +```bash +# Check your auth header +curl -H "Authorization: Bearer test-integration-token" http://localhost:18789/ + +# Verify token in startup command +grep "token test-integration-token" tests/integration/scripts/entrypoint-openclaw.sh +``` + +--- + +### "OpenClaw: Mission Control API unreachable" + +**Check logs:** +```bash +docker-compose -f docker-compose.integration.yml logs openclaw-gateway | grep -i "api\|connect\|unreachable" +``` + +**Fix:** +```bash +# Verify Mission Control is running and healthy +docker-compose -f docker-compose.integration.yml ps mission-control + +# Verify URL is correct (should point to mission-control service, not localhost) +grep MISSION_CONTROL_API_URL docker-compose.integration.yml +# Should be: http://mission-control:3000 (not http://localhost:3000) + +# Test from inside OpenClaw container +docker exec mission-control-openclaw-gateway-1 \ + curl http://mission-control:3000/api/health +``` + +--- + +### "Database migration failed" (initial_schema migration error) + +**Check:** +```bash +docker-compose -f docker-compose.integration.yml logs supabase-db | grep -i "migration\|error" | head -10 +``` + +**Fix:** +```bash +# Verify migrations are mounted correctly +docker-compose -f docker-compose.integration.yml config | grep migrations + +# Restart database to re-run migrations +docker-compose -f docker-compose.integration.yml down supabase-db +docker-compose -f docker-compose.integration.yml up -d supabase-db + +# Wait for healthy +sleep 10 && docker-compose -f docker-compose.integration.yml ps supabase-db +``` + +--- + +### "curl: command not found" in health checks + +**Not actually a failure.** PostgREST image doesn't have curl, so the health check uses: +```yaml +test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/3000'"] +``` + +This is correct. If you see actual health failures, look at the service logs instead. + +--- + +## Port Cheat Sheet + +| Service | Container Port | Host Port | URL | +|---------|---|---|---| +| PostgreSQL | 5432 | 54332 | `postgres://localhost:54332` | +| GoTrue (auth) | 9999 | 9998 | `http://localhost:9998` | +| PostgREST (data) | 3000 | 3001 | `http://localhost:3001` | +| Kong (gateway) | 8000 | 8000 | `http://localhost:8000` | +| Mission Control | 3000 | 3100 | `http://localhost:3100` | +| OpenClaw Gateway | 18789 | 18789 | `http://localhost:18789` | + +--- + +## One-Command Cleanup + +```bash +# Stop all containers and remove volumes (WARNING: data loss) +docker-compose -f docker-compose.integration.yml down -v + +# Remove dangling volumes +docker volume prune -f + +# Fresh start +docker-compose -f docker-compose.integration.yml up -d +docker-compose -f docker-compose.integration.yml logs -f +``` + +--- + +## Verify Each Service (Sequential) + +Run these in order to pinpoint which service is broken: + +```bash +# 1. PostgreSQL +echo "1. PostgreSQL..." +docker exec mission-control-supabase-db-1 pg_isready -U postgres && echo "✓ OK" || echo "✗ FAIL" + +# 2. GoTrue +echo "2. GoTrue..." +curl -sf http://localhost:9998/health > /dev/null && echo "✓ OK" || echo "✗ FAIL" + +# 3. PostgREST +echo "3. PostgREST..." +bash -c "echo > /dev/tcp/localhost/3001" 2>/dev/null && echo "✓ OK" || echo "✗ FAIL" + +# 4. Kong +echo "4. Kong..." +curl -sf http://localhost:8000/auth/v1/health > /dev/null && echo "✓ OK" || echo "✗ FAIL" + +# 5. Mission Control +echo "5. Mission Control..." +curl -sf http://localhost:3100/api/health > /dev/null && echo "✓ OK" || echo "✗ FAIL" + +# 6. OpenClaw +echo "6. OpenClaw..." +curl -sf -H "Authorization: Bearer test-integration-token" http://localhost:18789/ > /dev/null && echo "✓ OK" || echo "✗ FAIL" +``` + +--- + +## Debug Commands + +```bash +# See what's listening on a port +lsof -i :3000 + +# Watch service startup in real-time +docker-compose -f docker-compose.integration.yml logs -f mission-control + +# Run a one-off command in a container +docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c "..." + +# Copy a file out of container +docker cp mission-control-supabase-db-1:/var/log/postgres.log /tmp/postgres.log + +# Check container resource usage +docker stats mission-control-supabase-db-1 + +# Inspect network +docker network inspect mission-control_integration +``` + +--- + +## Config Validation One-Liners + +```bash +# Validate OpenClaw JSON +node -e "JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))" + +# Validate docker-compose YAML +docker-compose -f docker-compose.integration.yml config > /dev/null + +# Validate mission-control Dockerfile +docker build --dry-run apps/web/Dockerfile . + +# Check all JSON files in openclaw directory +find tests/integration/openclaw -name "*.json" -exec sh -c 'node -e "JSON.parse(require(\"fs\").readFileSync(\"$1\"))" _ {} \; +``` + +--- + +## Before Committing Changes + +1. Validate configs: + ```bash + docker-compose -f docker-compose.integration.yml config > /dev/null && \ + node -e "JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))" && \ + echo "✓ All configs valid" + ``` + +2. Test on clean environment: + ```bash + docker-compose -f docker-compose.integration.yml down -v + docker-compose -f docker-compose.integration.yml up -d + sleep 60 # Let services start + curl http://localhost:3100/api/health # Should return 200 + ``` + +3. Check Git diff for unintended changes: + ```bash + git diff docker-compose.integration.yml # Should be minimal + git diff tests/integration/openclaw/config/ + ``` + +--- + +## Integration Test Execution + +```bash +# Run integration tests +docker-compose -f docker-compose.integration.yml up -d +sleep 60 # Wait for services to be healthy + +# In another terminal: +pnpm test:integration + +# Or specific test: +pnpm test tests/integration/api/heartbeat.test.ts + +# Stop services +docker-compose -f docker-compose.integration.yml down +``` + +--- + +## When Nothing Works + +**Nuclear option (complete reset):** + +```bash +# Stop everything +docker-compose -f docker-compose.integration.yml down -v +docker volume prune -f +docker network prune -f + +# Remove image to force rebuild +docker rmi openclaw:local 2>/dev/null || true + +# Rebuild and start fresh +docker-compose -f docker-compose.integration.yml build --no-cache +docker-compose -f docker-compose.integration.yml up -d + +# Wait and verify +sleep 120 +docker-compose -f docker-compose.integration.yml ps +curl http://localhost:3100/api/health +``` + +--- + +## Still Stuck? + +1. **Check the full prevention guide:** See `rule 10` for detailed explanations +2. **Read service logs:** `docker-compose logs ` +3. **Ask the team:** The 12 issues documented in rule 10 cover ~95% of failures + +--- + +## Last Updated + +- **2026-02-05** — Initial quick reference based on integration setup +- **Changes tested:** All 12 common issues and their fixes diff --git a/.claude/rules/12-dashboard-ui-quick-reference.md b/.claude/rules/12-dashboard-ui-quick-reference.md new file mode 100644 index 0000000..4b99517 --- /dev/null +++ b/.claude/rules/12-dashboard-ui-quick-reference.md @@ -0,0 +1,202 @@ +# Dashboard UI Overhaul — Quick Reference Card + +Checklist for redesigning header, sidebar, kanban, live feed, agent profile, and task detail panels. + +--- + +## Pre-Build Checklist + +- [ ] Review `docs/DASHBOARD_UI_LEARNINGS.md` (full learnings) +- [ ] Audit tailwind.config for available design tokens +- [ ] Verify all components use `cn()` utility for class composition +- [ ] Check existing atoms/molecules directory structure +- [ ] Understand forwardRef + displayName pattern + +--- + +## Component Build Checklist + +For EVERY component (atom, molecule, organism): + +### Structure +- [ ] Use `forwardRef` for molecule-level+ components +- [ ] Add `.displayName = 'ComponentName'` after export +- [ ] Extract variant/size styles into Record types +- [ ] Use `cn()` to merge Tailwind classes + +### Accessibility +- [ ] **Dialog title**: Always unconditional + `sr-only` if invisible +- [ ] Focus rings: Add `focus-visible:ring-2 focus-visible:ring-offset-2` to all interactive elements +- [ ] Form labels: Use `sr-only` class, never conditional +- [ ] Semantic HTML: Use ` - - - )} - - {!error && squads && squads.length > 0 && ( -
- {squads.map((squad) => ( - - - {squad.name} - - {squad.description && ( - - {squad.description} - - )} - - Created{' '} - {new Date(squad.created_at!).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - - ))} -
- )} - - - ) +export default function DashboardPage() { + redirect('/tasks') } diff --git a/apps/web/src/app/(dashboard)/tasks/page.tsx b/apps/web/src/app/(dashboard)/tasks/page.tsx index e362a55..d13b02e 100644 --- a/apps/web/src/app/(dashboard)/tasks/page.tsx +++ b/apps/web/src/app/(dashboard)/tasks/page.tsx @@ -79,16 +79,6 @@ export default async function TasksPage() { return (
-
- - Tasks - - - {transformedTasks.length} {transformedTasks.length === 1 ? 'task' : 'tasks'} across your - squads - -
- {error && (
Error loading tasks: {error.message} diff --git a/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx b/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx index 0e598ab..8bc4f16 100644 --- a/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx +++ b/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx @@ -1,11 +1,24 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { KanbanBoard, type TaskData, type TaskStatus } from '@/components/organisms/KanbanBoard' import { TaskModal, type TaskFormValues } from '@/components/organisms/TaskModal' -import { createClient } from '@/lib/supabase/client' +import { TaskDetailPanel } from '@/components/templates/TaskDetailPanel' +import { StatusDot, Text, Icon } from '@/components/atoms' +import { DeliverableView } from '@/components/molecules/DeliverableView' +import { StatusFilterBar } from '@/components/molecules/StatusFilterBar' +import { createClient } from '@/lib/supabase/browser' + +interface FetchedDeliverable { + id: string + title: string + content: string | null + type: string + created_at: string + agents: { name: string } | null +} interface TasksClientProps { initialTasks: TaskData[] @@ -18,35 +31,123 @@ export function TasksClient({ initialTasks }: TasksClientProps) { // Local state for tasks (allows optimistic updates) const [tasks, setTasks] = useState(initialTasks) + // Filter state + const [activeFilter, setActiveFilter] = useState(null) + // Modal state const [selectedTask, setSelectedTask] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) - // Auto-open task modal from URL query parameter + // Detail panel state + const [isDetailOpen, setIsDetailOpen] = useState(false) + + // Deliverables state + const [deliverables, setDeliverables] = useState([]) + const [selectedDeliverable, setSelectedDeliverable] = useState(null) + const userDismissedDeliverable = useRef(false) + + // Compute status counts from all tasks + const statusCounts = useMemo(() => { + const counts: Record = {} + tasks.forEach((t) => { + counts[t.status] = (counts[t.status] || 0) + 1 + }) + return counts + }, [tasks]) + + // Filter tasks based on active filter + const filteredTasks = useMemo(() => { + if (!activeFilter) return tasks + return tasks.filter((t) => t.status === activeFilter) + }, [tasks, activeFilter]) + + // Count of tasks that are actively being worked + const activeTasksCount = tasks.filter((t) => + ['assigned', 'in_progress'].includes(t.status) + ).length + + // Status configurations for the filter bar + const statusConfigs = useMemo( + () => [ + { status: 'inbox', label: 'Inbox', count: statusCounts.inbox || 0, color: 'bg-text-muted' }, + { status: 'assigned', label: 'Assigned', count: statusCounts.assigned || 0, color: 'bg-priority-normal' }, + { status: 'in_progress', label: 'In Progress', count: statusCounts.in_progress || 0, color: 'bg-status-active' }, + { status: 'review', label: 'Review', count: statusCounts.review || 0, color: 'bg-status-idle' }, + { status: 'done', label: 'Done', count: statusCounts.done || 0, color: 'bg-status-active' }, + ], + [statusCounts] + ) + + // Auto-open task detail panel from URL query parameter useEffect(() => { const taskId = searchParams.get('task') if (taskId && tasks.length > 0) { const task = tasks.find((t) => t.id === taskId) if (task) { setSelectedTask(task) - setIsModalOpen(true) + setIsDetailOpen(true) } // Clear the query param to avoid re-opening on refresh router.replace('/tasks', { scroll: false }) } }, [searchParams, tasks, router]) + // Fetch deliverables when task detail panel opens + useEffect(() => { + if (!selectedTask || !isDetailOpen) { + setDeliverables([]) + setSelectedDeliverable(null) + userDismissedDeliverable.current = false + return + } + + const fetchDeliverables = async () => { + userDismissedDeliverable.current = false + const supabase = createClient() + const { data, error } = await supabase + .from('documents') + .select('id, title, content, type, created_at, agents:created_by_agent_id(name)') + .eq('task_id', selectedTask.id) + .order('created_at', { ascending: false }) + + if (!error && data) { + setDeliverables(data as unknown as FetchedDeliverable[]) + } + } + + fetchDeliverables() + }, [selectedTask, isDetailOpen]) + + // Auto-select single deliverable for direct content view + useEffect(() => { + if (deliverables.length === 1 && !selectedDeliverable && !userDismissedDeliverable.current) { + setSelectedDeliverable(deliverables[0]) + } + }, [deliverables, selectedDeliverable]) + /** - * Handle task click - open the modal with the selected task + * Open the edit modal for a task (closes detail panel first) */ - const handleTaskClick = useCallback((taskId: string) => { + const handleEditTask = useCallback((taskId: string) => { const task = tasks.find((t) => t.id === taskId) if (task) { setSelectedTask(task) + setIsDetailOpen(false) setIsModalOpen(true) } }, [tasks]) + /** + * Handle task click - show slide-over detail panel + */ + const handleTaskClick = useCallback((taskId: string) => { + const task = tasks.find((t) => t.id === taskId) + if (task) { + setSelectedTask(task) + setIsDetailOpen(true) + } + }, [tasks]) + /** * Handle closing the modal */ @@ -141,13 +242,105 @@ export function TasksClient({ initialTasks }: TasksClientProps) { // This is a simplified implementation that updates core task fields }, [selectedTask]) + /** + * Local component for rendering deliverables list or detail view + */ + const DeliverablesSection = () => { + if (deliverables.length === 0) { + return null + } + + // If a deliverable is selected, show the full document view + if (selectedDeliverable) { + return ( + { + userDismissedDeliverable.current = true + setSelectedDeliverable(null) + }} + /> + ) + } + + // Show deliverables list + return ( +
+ + DELIVERABLES ({deliverables.length}) + +
+ {deliverables.map((doc) => ( + + ))} +
+
+ ) + } + return ( <> - +
+ {/* Header row */} +
+
+ + + MISSION QUEUE + +
+
+
+ + {tasks.length} +
+ + {activeTasksCount} active + +
+
+ + {/* Filter bar */} + + + {/* Board */} + +
+ + { + if (!open) { + setIsDetailOpen(false) + setSelectedTask(null) + setSelectedDeliverable(null) + } + }} + task={selectedTask} + > + + ) } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 1a4bf2e..7b49467 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -66,3 +66,23 @@ body { color: var(--foreground); font-family: var(--font-sans); } + +@keyframes feed-entry-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Utility: hide scrollbar while keeping scroll functionality */ +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3264a4d..5ae81a7 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -9,7 +9,7 @@ export default async function Home() { } = await supabase.auth.getUser() if (user) { - redirect('/dashboard') + redirect('/tasks') } else { redirect('/login') } diff --git a/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx b/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx new file mode 100644 index 0000000..75d29c2 --- /dev/null +++ b/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx @@ -0,0 +1,143 @@ +'use client' + +import { forwardRef, useCallback } from 'react' +import type { KeyboardEvent } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Props for the AgentFilterChips component + */ +export interface AgentFilterChipsProps { + /** List of agents to display as filter chips */ + agents: Array<{ id: string; name: string; activityCount?: number }> + /** Currently selected agent ID, or null for "All" */ + selectedAgentId: string | null + /** Callback when an agent chip is selected */ + onAgentSelect: (agentId: string | null) => void + /** Additional CSS classes */ + className?: string +} + +/** + * AgentFilterChips molecule component + * + * A horizontally scrollable row of agent chips for filtering the live feed + * by a specific agent. Implements a radio group pattern for accessibility. + * + * @example + * ```tsx + * setSelected(id)} + * /> + * ``` + */ +export const AgentFilterChips = forwardRef( + function AgentFilterChips({ agents, selectedAgentId, onAgentSelect, className }, ref) { + const allChips = [ + { id: null as string | null, name: 'All', activityCount: undefined as number | undefined }, + ...agents, + ] + + const currentIndex = allChips.findIndex( + (chip) => chip.id === selectedAgentId + ) + + const handleKeyDown = useCallback( + (event: KeyboardEvent, index: number) => { + let nextIndex: number | null = null + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault() + nextIndex = index < allChips.length - 1 ? index + 1 : 0 + break + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault() + nextIndex = index > 0 ? index - 1 : allChips.length - 1 + break + case 'Home': + event.preventDefault() + nextIndex = 0 + break + case 'End': + event.preventDefault() + nextIndex = allChips.length - 1 + break + case 'Enter': + case ' ': + event.preventDefault() + onAgentSelect(allChips[index].id) + return + } + + if (nextIndex !== null) { + onAgentSelect(allChips[nextIndex].id) + const button = document.querySelector( + `[data-testid="agent-chip-${allChips[nextIndex].id ?? 'all'}"]` + ) as HTMLButtonElement | null + button?.focus() + } + }, + [allChips, onAgentSelect] + ) + + return ( +
+ {allChips.map((chip, index) => { + const isSelected = chip.id === selectedAgentId + + return ( + + ) + })} +
+ ) + } +) + +AgentFilterChips.displayName = 'AgentFilterChips' diff --git a/apps/web/src/components/molecules/AgentFilterChips/index.ts b/apps/web/src/components/molecules/AgentFilterChips/index.ts new file mode 100644 index 0000000..87cce73 --- /dev/null +++ b/apps/web/src/components/molecules/AgentFilterChips/index.ts @@ -0,0 +1 @@ +export { AgentFilterChips, type AgentFilterChipsProps } from './AgentFilterChips' diff --git a/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx b/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx new file mode 100644 index 0000000..70a7982 --- /dev/null +++ b/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx @@ -0,0 +1,222 @@ +import { forwardRef } from 'react' + +import { Avatar, Icon, StatusDot, Text } from '@/components/atoms' +import type { AgentData } from '@/components/organisms/AgentSidebar' +import { cn } from '@/lib/utils' + +export interface AgentListItemProps { + /** Agent data to display */ + agent: AgentData + /** Whether this item is currently selected */ + isSelected: boolean + /** Callback when the item is clicked */ + onClick: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * Maps a role string to a role type badge label and className. + */ +function getRoleType(role: string): { label: string; className: string } { + const lower = role.toLowerCase() + if ( + lower.includes('lead') || + lower.includes('coordinator') || + lower.includes('manager') + ) { + return { label: 'LEAD', className: 'bg-amber-700/15 text-amber-700' } + } + if ( + lower.includes('writer') || + lower.includes('editor') || + lower.includes('social') || + lower.includes('content') + ) { + return { label: 'INT', className: 'bg-emerald-700/15 text-emerald-700' } + } + return { label: 'SPC', className: 'bg-orange-700/15 text-orange-700' } +} + +/** + * Maps agent status to a display label and color className. + */ +function getStatusText( + status: AgentData['status'] +): { label: string; className: string } { + switch (status) { + case 'active': + return { label: 'WORKING', className: 'text-status-active' } + case 'idle': + return { label: 'IDLE', className: 'text-status-idle' } + case 'blocked': + return { label: 'BLOCKED', className: 'text-status-blocked' } + case 'offline': + return { label: 'OFFLINE', className: 'text-status-offline' } + } +} + +/** + * Maps agent status to StatusDot status. + * StatusDot does not support 'blocked', so we map it to 'offline'. + */ +function getStatusDotStatus( + status: AgentData['status'] +): 'active' | 'idle' | 'offline' { + if (status === 'blocked') return 'offline' + return status +} + +/** + * Maps avatar color strings to valid Avatar color props. + */ +function getAvatarColor( + color?: string +): 'accent' | 'blue' | 'green' | 'purple' | 'orange' | 'pink' | 'teal' { + const validColors = [ + 'accent', + 'blue', + 'green', + 'purple', + 'orange', + 'pink', + 'teal', + ] as const + if (color && validColors.includes(color as (typeof validColors)[number])) { + return color as (typeof validColors)[number] + } + return 'accent' +} + +/** + * AgentListItem molecule component + * + * Renders a single agent row in the sidebar with avatar, status overlay, + * role badge, status text label, and optional current task / blocked reason. + * + * @example + * ```tsx + * handleAgentClick(agent)} + * /> + * ``` + */ +export const AgentListItem = forwardRef( + ({ agent, isSelected, onClick, className }, ref) => { + const statusDotStatus = getStatusDotStatus(agent.status) + const roleType = getRoleType(agent.role) + const statusText = getStatusText(agent.status) + + return ( + + ) + } +) + +AgentListItem.displayName = 'AgentListItem' diff --git a/apps/web/src/components/molecules/AgentListItem/index.ts b/apps/web/src/components/molecules/AgentListItem/index.ts new file mode 100644 index 0000000..27919aa --- /dev/null +++ b/apps/web/src/components/molecules/AgentListItem/index.ts @@ -0,0 +1,2 @@ +export { AgentListItem } from './AgentListItem' +export type { AgentListItemProps } from './AgentListItem' diff --git a/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx b/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx new file mode 100644 index 0000000..67ce5c1 --- /dev/null +++ b/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx @@ -0,0 +1,111 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Badge, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +type AgentStatus = 'active' | 'idle' | 'blocked' | 'offline' + +export interface AgentStatusBoxProps { + /** Current agent status */ + status: AgentStatus + /** Reason for current status (e.g., "Waiting for review") */ + statusReason?: string | null + /** ISO timestamp for when this status started */ + statusSince?: string | null + /** Additional CSS classes */ + className?: string +} + +/** + * Maps agent status to border color token + */ +const statusBorderStyles: Record = { + active: 'border-status-active', + idle: 'border-status-idle', + blocked: 'border-status-blocked', + offline: 'border-status-offline', +} + +/** + * Maps agent status to badge variant + */ +const statusBadgeVariant: Record< + AgentStatus, + 'status-active' | 'status-idle' | 'status-blocked' | 'status-offline' +> = { + active: 'status-active', + idle: 'status-idle', + blocked: 'status-blocked', + offline: 'status-offline', +} + +/** + * Human-readable status labels + */ +const statusLabels: Record = { + active: 'Working', + idle: 'Idle', + blocked: 'Blocked', + offline: 'Offline', +} + +/** + * AgentStatusBox molecule component + * + * Bordered container showing agent status with optional reason and timing. + * The border color reflects the current agent status. + * + * @example + * ```tsx + * + * ``` + */ +export const AgentStatusBox = forwardRef( + ({ status, statusReason, statusSince, className }, ref) => { + return ( +
+
+ + {statusLabels[status]} + + + {statusReason && ( +
+ + Status Reason: + + + {statusReason} + +
+ )} + + {statusSince && ( + + Since {formatRelativeTime(statusSince)} + + )} +
+
+ ) + } +) + +AgentStatusBox.displayName = 'AgentStatusBox' diff --git a/apps/web/src/components/molecules/AgentStatusBox/index.ts b/apps/web/src/components/molecules/AgentStatusBox/index.ts new file mode 100644 index 0000000..45b66bb --- /dev/null +++ b/apps/web/src/components/molecules/AgentStatusBox/index.ts @@ -0,0 +1,2 @@ +export { AgentStatusBox } from './AgentStatusBox' +export type { AgentStatusBoxProps } from './AgentStatusBox' diff --git a/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx b/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx new file mode 100644 index 0000000..b7bed80 --- /dev/null +++ b/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx @@ -0,0 +1,83 @@ +import { forwardRef } from 'react' + +import { Icon, Text } from '@/components/atoms' +import { cn } from '@/lib/utils' + +export interface AllAgentsRowProps { + /** Total number of agents */ + totalCount: number + /** Number of currently active agents */ + activeCount: number + /** Whether this row is currently selected */ + isSelected: boolean + /** Callback when the row is clicked */ + onClick: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * AllAgentsRow molecule component + * + * Summary row shown at the top of the agent sidebar. Displays total and + * active agent counts with a Users icon. Supports selected state styling. + * + * @example + * ```tsx + * handleAllAgentsClick()} + * /> + * ``` + */ +export const AllAgentsRow = forwardRef( + ({ totalCount, activeCount, isSelected, onClick, className }, ref) => { + return ( + + ) + } +) + +AllAgentsRow.displayName = 'AllAgentsRow' diff --git a/apps/web/src/components/molecules/AllAgentsRow/index.ts b/apps/web/src/components/molecules/AllAgentsRow/index.ts new file mode 100644 index 0000000..ce49cea --- /dev/null +++ b/apps/web/src/components/molecules/AllAgentsRow/index.ts @@ -0,0 +1,2 @@ +export { AllAgentsRow } from './AllAgentsRow' +export type { AllAgentsRowProps } from './AllAgentsRow' diff --git a/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx new file mode 100644 index 0000000..7f8bda8 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import { AssigneeDisplay } from './AssigneeDisplay' + +describe('AssigneeDisplay', () => { + const mockAssignee = { + id: 'user-1', + name: 'Alice', + avatarColor: 'blue', + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-02-05T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the assignee name', () => { + render() + expect(screen.getByText('Alice')).toBeInTheDocument() + }) + + it('renders avatar with correct title', () => { + render() + expect(screen.getByTitle('Alice')).toBeInTheDocument() + }) + + it('renders relative timestamp when provided', () => { + render( + + ) + expect(screen.getByText('5m ago')).toBeInTheDocument() + }) + + it('does not render timestamp when not provided', () => { + render() + // Only the name text should be present, no timestamp + const container = screen.getByTestId('assignee-display') + const textElements = container.querySelectorAll('span') + // Should not contain a relative time string + const allText = container.textContent + expect(allText).not.toContain('ago') + }) + + it('applies custom className', () => { + render( + + ) + const container = screen.getByTestId('assignee-display') + expect(container).toHaveClass('custom-class') + }) + + it('has correct test id', () => { + render() + expect(screen.getByTestId('assignee-display')).toBeInTheDocument() + }) + + it('handles assignee without avatar color', () => { + const noColor = { id: 'user-2', name: 'Bob' } + render() + expect(screen.getByText('Bob')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx new file mode 100644 index 0000000..7591bf1 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx @@ -0,0 +1,97 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/formatRelativeTime' +import { Avatar, Text } from '@/components/atoms' + +/** + * Assignee data for display purposes + */ +export interface AssigneeDisplayData { + /** Unique identifier for the assignee */ + id: string + /** Display name */ + name: string + /** Optional avatar color */ + avatarColor?: string +} + +/** + * Props for the AssigneeDisplay component + */ +export interface AssigneeDisplayProps + extends React.HTMLAttributes { + /** The assignee to display */ + assignee: AssigneeDisplayData + /** Optional timestamp to show as relative time (right-aligned) */ + timestamp?: string | null +} + +const validAvatarColors = [ + 'accent', + 'blue', + 'green', + 'purple', + 'orange', + 'pink', + 'teal', +] as const + +type AvatarColor = (typeof validAvatarColors)[number] + +function toAvatarColor(color?: string): AvatarColor { + if (color && validAvatarColors.includes(color as AvatarColor)) { + return color as AvatarColor + } + return 'accent' +} + +/** + * AssigneeDisplay molecule component + * + * Shows an assignee avatar (xs size), their name, and an optional + * relative timestamp aligned to the right. + * + * @example + * ```tsx + * + * ``` + */ +export const AssigneeDisplay = forwardRef( + ({ assignee, timestamp, className, ...props }, ref) => { + return ( +
+ + + {assignee.name} + + {timestamp && ( + + {formatRelativeTime(timestamp)} + + )} +
+ ) + } +) + +AssigneeDisplay.displayName = 'AssigneeDisplay' diff --git a/apps/web/src/components/molecules/AssigneeDisplay/index.ts b/apps/web/src/components/molecules/AssigneeDisplay/index.ts new file mode 100644 index 0000000..500cdb4 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/index.ts @@ -0,0 +1,2 @@ +export { AssigneeDisplay } from './AssigneeDisplay' +export type { AssigneeDisplayProps, AssigneeDisplayData } from './AssigneeDisplay' diff --git a/apps/web/src/components/molecules/AttentionList/AttentionList.tsx b/apps/web/src/components/molecules/AttentionList/AttentionList.tsx new file mode 100644 index 0000000..dfc9a74 --- /dev/null +++ b/apps/web/src/components/molecules/AttentionList/AttentionList.tsx @@ -0,0 +1,113 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +export interface AttentionItem { + id: string + type: 'mention' | 'waiting_task' + title: string + description?: string + timestamp?: string +} + +export interface AttentionListProps { + /** List of items requiring agent attention */ + items: AttentionItem[] + /** Additional CSS classes */ + className?: string +} + +/** + * Maps attention item type to an icon name + */ +function getItemIcon(type: AttentionItem['type']): 'MessageSquare' | 'Clock' { + return type === 'mention' ? 'MessageSquare' : 'Clock' +} + +/** + * Maps attention item type to a human-readable label + */ +function getItemTypeLabel(type: AttentionItem['type']): string { + return type === 'mention' ? 'Mention' : 'Waiting Task' +} + +/** + * AttentionList molecule component + * + * Displays a list of items requiring an agent's attention, such as + * unread mentions and waiting tasks. Shows an empty state when there + * are no items. + * + * @example + * ```tsx + * + * ``` + */ +export const AttentionList = forwardRef( + ({ items, className }, ref) => { + if (items.length === 0) { + return ( +
+ + + No items need attention + +
+ ) + } + + return ( +
+ {items.map((item) => ( +
+
+ +
+
+ + {item.title} + + {item.description && ( + + {item.description} + + )} + {item.timestamp && ( + + {formatRelativeTime(item.timestamp)} + + )} +
+
+ ))} +
+ ) + } +) + +AttentionList.displayName = 'AttentionList' diff --git a/apps/web/src/components/molecules/AttentionList/index.ts b/apps/web/src/components/molecules/AttentionList/index.ts new file mode 100644 index 0000000..6ee319e --- /dev/null +++ b/apps/web/src/components/molecules/AttentionList/index.ts @@ -0,0 +1,2 @@ +export { AttentionList } from './AttentionList' +export type { AttentionListProps, AttentionItem } from './AttentionList' diff --git a/apps/web/src/components/molecules/Clock/Clock.tsx b/apps/web/src/components/molecules/Clock/Clock.tsx index a030d45..8bfefe8 100644 --- a/apps/web/src/components/molecules/Clock/Clock.tsx +++ b/apps/web/src/components/molecules/Clock/Clock.tsx @@ -51,11 +51,13 @@ function formatTime( * Formats a Date object to a date string. */ function formatDate(date: Date): string { - return date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - }) + return date + .toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }) + .toUpperCase() } /** diff --git a/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx b/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx new file mode 100644 index 0000000..dcd66dd --- /dev/null +++ b/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from 'react' +import { StatusDot } from '@/components/atoms' +import { cn } from '@/lib/utils' + +export interface ConnectionStatusProps + extends React.HTMLAttributes { + /** Whether the dashboard is connected to the backend */ + isConnected: boolean + /** Additional CSS classes */ + className?: string +} + +/** + * ConnectionStatus - Displays connection state with a status dot and label. + * + * When connected: green dot + "ONLINE" + * When disconnected: red dot + "OFFLINE" + * + * @example + * ```tsx + * + * + * ``` + */ +export const ConnectionStatus = forwardRef< + HTMLDivElement, + ConnectionStatusProps +>(({ isConnected, className, ...props }, ref) => { + return ( +
+ + + {isConnected ? 'ONLINE' : 'OFFLINE'} + +
+ ) +}) + +ConnectionStatus.displayName = 'ConnectionStatus' diff --git a/apps/web/src/components/molecules/ConnectionStatus/index.ts b/apps/web/src/components/molecules/ConnectionStatus/index.ts new file mode 100644 index 0000000..418cd45 --- /dev/null +++ b/apps/web/src/components/molecules/ConnectionStatus/index.ts @@ -0,0 +1,4 @@ +export { + ConnectionStatus, + type ConnectionStatusProps, +} from './ConnectionStatus' diff --git a/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx b/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx new file mode 100644 index 0000000..bad906c --- /dev/null +++ b/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx @@ -0,0 +1,85 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Badge, Icon, Text } from '@/components/atoms' +import { MarkdownViewer } from '@/components/molecules/MarkdownViewer' + +export interface DeliverableDocument { + /** Document title */ + title: string + /** Markdown content of the document */ + content: string + /** Author name */ + author?: string + /** ISO date string for when the document was created */ + createdAt?: string +} + +export interface DeliverableViewProps { + /** The document to display */ + document: DeliverableDocument + /** Callback when the back button is clicked */ + onBack: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * DeliverableView displays a document with a back navigation header, + * a DELIVERABLE badge, and rendered markdown content. + * + * @example + * ```tsx + * setView('list')} + * /> + * ``` + */ +export const DeliverableView = forwardRef( + ({ document, onBack, className }, ref) => { + return ( +
+ {/* Back navigation + DELIVERABLE badge */} +
+ + + DELIVERABLE + +
+ + {/* Document title */} + + 📄 {document.title} + + + {/* Document content */} + +
+ ) + } +) + +DeliverableView.displayName = 'DeliverableView' diff --git a/apps/web/src/components/molecules/DeliverableView/index.ts b/apps/web/src/components/molecules/DeliverableView/index.ts new file mode 100644 index 0000000..d651490 --- /dev/null +++ b/apps/web/src/components/molecules/DeliverableView/index.ts @@ -0,0 +1,2 @@ +export { DeliverableView } from './DeliverableView' +export type { DeliverableViewProps, DeliverableDocument } from './DeliverableView' diff --git a/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx b/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx new file mode 100644 index 0000000..7c942fc --- /dev/null +++ b/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx @@ -0,0 +1,168 @@ +'use client' + +import { forwardRef } from 'react' +import Link from 'next/link' + +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/formatRelativeTime' +import { Avatar, Icon, Text } from '@/components/atoms' +import type { ActivityData, ActivityType } from '@/components/organisms/LiveFeed/LiveFeed' + +// Icon name type matching what we use from lucide +type IconNameType = + | 'ListPlus' + | 'ArrowRightLeft' + | 'UserCheck' + | 'MessageSquare' + | 'FilePlus' + | 'Zap' + | 'Activity' + +/** + * Props for the FeedEntry component + */ +export interface FeedEntryProps { + /** Activity data to render */ + activity: ActivityData + /** Function to generate href for task links */ + getTaskHref?: (taskId: string) => string + /** Additional CSS classes */ + className?: string +} + +/** + * Maps activity type to an icon name + */ +function getActivityIcon(type: ActivityType): IconNameType { + const iconMap: Record = { + task_created: 'ListPlus', + task_status_changed: 'ArrowRightLeft', + task_assigned: 'UserCheck', + message_sent: 'MessageSquare', + document_created: 'FilePlus', + agent_status_changed: 'Zap', + } + return iconMap[type] || 'Activity' +} + +/** + * Maps activity type to a color class for the icon badge + */ +function getActivityColor(type: ActivityType): string { + const colorMap: Record = { + task_created: 'bg-status-active/15 text-status-active', + task_status_changed: 'bg-accent/15 text-accent', + task_assigned: 'bg-accent-secondary text-accent', + message_sent: 'bg-accent-muted text-accent', + document_created: 'bg-status-blocked/15 text-status-blocked', + agent_status_changed: 'bg-status-idle/15 text-status-idle', + } + return colorMap[type] || 'bg-background-elevated text-text-muted' +} + +/** + * FeedEntry molecule component + * + * A rich activity entry for the live feed. Shows agent avatar (or type icon), + * agent name, action message, relative timestamp, and a small type badge. + * Task-linked entries are rendered as clickable links. + * + * @example + * ```tsx + * `/tasks/${id}`} + * /> + * ``` + */ +export const FeedEntry = forwardRef( + function FeedEntry({ activity, getTaskHref, className }, ref) { + const iconName = getActivityIcon(activity.type) + const colorClass = getActivityColor(activity.type) + const relativeTime = formatRelativeTime(activity.created_at) + const messageId = `feed-entry-message-${activity.id}` + + const taskHref = activity.task_id + ? getTaskHref + ? getTaskHref(activity.task_id) + : `/tasks/${activity.task_id}` + : null + + const content = ( + <> + {/* Agent avatar or activity type icon */} +
+ {activity.agent_name ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Activity content */} +
+

+ {activity.agent_name && ( + {activity.agent_name} + )} + {activity.message} +

+ + {relativeTime} + +
+ + {/* Activity type badge */} +
+ +
+ + ) + + if (taskHref) { + return ( +
  • + + {content} + +
  • + ) + } + + return ( +
  • + {content} +
  • + ) + } +) + +FeedEntry.displayName = 'FeedEntry' diff --git a/apps/web/src/components/molecules/FeedEntry/index.ts b/apps/web/src/components/molecules/FeedEntry/index.ts new file mode 100644 index 0000000..f7318c3 --- /dev/null +++ b/apps/web/src/components/molecules/FeedEntry/index.ts @@ -0,0 +1 @@ +export { FeedEntry, type FeedEntryProps } from './FeedEntry' diff --git a/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx b/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx new file mode 100644 index 0000000..777be9f --- /dev/null +++ b/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx @@ -0,0 +1,149 @@ +'use client' + +import { forwardRef, useCallback } from 'react' +import type { KeyboardEvent } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Feed type for filtering activities + */ +export type FeedType = 'all' | 'tasks' | 'comments' | 'docs' | 'status' + +/** + * Props for the FeedTypeTabs component + */ +export interface FeedTypeTabsProps { + /** Currently active feed type */ + activeType: FeedType + /** Callback when a tab is selected */ + onTypeChange: (type: FeedType) => void + /** Optional counts per feed type */ + counts?: Partial> + /** Additional CSS classes */ + className?: string +} + +/** + * Tab configuration + */ +interface TabConfig { + value: FeedType + label: string +} + +const TABS: TabConfig[] = [ + { value: 'all', label: 'All' }, + { value: 'tasks', label: 'Tasks' }, + { value: 'comments', label: 'Comments' }, + { value: 'docs', label: 'Docs' }, + { value: 'status', label: 'Status' }, +] + +/** + * FeedTypeTabs molecule component + * + * A tab bar for filtering live feed activities by type. + * Implements the WAI-ARIA tablist pattern for accessibility. + * + * @example + * ```tsx + * const [type, setType] = useState('all') + * + * + * ``` + */ +export const FeedTypeTabs = forwardRef( + function FeedTypeTabs({ activeType, onTypeChange, counts, className }, ref) { + const handleKeyDown = useCallback( + (event: KeyboardEvent, index: number) => { + let nextIndex: number | null = null + + switch (event.key) { + case 'ArrowRight': + event.preventDefault() + nextIndex = index < TABS.length - 1 ? index + 1 : 0 + break + case 'ArrowLeft': + event.preventDefault() + nextIndex = index > 0 ? index - 1 : TABS.length - 1 + break + case 'Home': + event.preventDefault() + nextIndex = 0 + break + case 'End': + event.preventDefault() + nextIndex = TABS.length - 1 + break + } + + if (nextIndex !== null) { + onTypeChange(TABS[nextIndex].value) + const button = document.querySelector( + `[data-testid="feed-tab-${TABS[nextIndex].value}"]` + ) as HTMLButtonElement | null + button?.focus() + } + }, + [onTypeChange] + ) + + return ( +
    + {TABS.map((tab, index) => { + const isSelected = tab.value === activeType + const count = counts?.[tab.value] + + return ( + + ) + })} +
    + ) + } +) + +FeedTypeTabs.displayName = 'FeedTypeTabs' diff --git a/apps/web/src/components/molecules/FeedTypeTabs/index.ts b/apps/web/src/components/molecules/FeedTypeTabs/index.ts new file mode 100644 index 0000000..11caa98 --- /dev/null +++ b/apps/web/src/components/molecules/FeedTypeTabs/index.ts @@ -0,0 +1 @@ +export { FeedTypeTabs, type FeedTypeTabsProps, type FeedType } from './FeedTypeTabs' diff --git a/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx b/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx new file mode 100644 index 0000000..d0585bd --- /dev/null +++ b/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +export interface FilterIndicatorProps + extends React.HTMLAttributes { + /** Name of the agent being filtered */ + agentName: string + /** Number of tasks for this agent */ + taskCount: number + /** Additional CSS classes */ + className?: string +} + +/** + * FilterIndicator - Displays the active agent filter replacing the stats area. + * + * Shows "FILTERING BY [Agent Name]" with task count below. + * Agent name is highlighted in accent color. + * + * @example + * ```tsx + * + * ``` + */ +export const FilterIndicator = forwardRef( + ({ agentName, taskCount, className, ...props }, ref) => { + return ( +
    +
    + FILTERING BY + {agentName} +
    + + {taskCount} {taskCount === 1 ? 'TASK' : 'TASKS'} + +
    + ) + } +) + +FilterIndicator.displayName = 'FilterIndicator' diff --git a/apps/web/src/components/molecules/FilterIndicator/index.ts b/apps/web/src/components/molecules/FilterIndicator/index.ts new file mode 100644 index 0000000..46c0e6a --- /dev/null +++ b/apps/web/src/components/molecules/FilterIndicator/index.ts @@ -0,0 +1,4 @@ +export { + FilterIndicator, + type FilterIndicatorProps, +} from './FilterIndicator' diff --git a/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx b/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx new file mode 100644 index 0000000..b2c701b --- /dev/null +++ b/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx @@ -0,0 +1,146 @@ +'use client' + +import { forwardRef } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' + +export interface MarkdownViewerProps { + /** Markdown content string to render */ + content: string + /** Additional CSS classes */ + className?: string +} + +/** + * MarkdownViewer renders markdown content with design-token-based styling. + * + * Supports GFM (GitHub Flavored Markdown) including tables, strikethrough, + * task lists, and autolinks via remark-gfm. + * + * @example + * ```tsx + * + * ``` + */ +export const MarkdownViewer = forwardRef( + ({ content, className }, ref) => { + return ( +
    + ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + h4: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    {children}

    + ), + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ children, className: codeClassName }) => { + // Inline code (no language class) vs block code + const isBlock = codeClassName?.startsWith('language-') + if (isBlock) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + pre: ({ children }) => ( +
    +                {children}
    +              
    + ), + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => ( + {children} + ), + del: ({ children }) => ( + {children} + ), + }} + > + {content} +
    +
    + ) + } +) + +MarkdownViewer.displayName = 'MarkdownViewer' diff --git a/apps/web/src/components/molecules/MarkdownViewer/index.ts b/apps/web/src/components/molecules/MarkdownViewer/index.ts new file mode 100644 index 0000000..1e7688a --- /dev/null +++ b/apps/web/src/components/molecules/MarkdownViewer/index.ts @@ -0,0 +1,2 @@ +export { MarkdownViewer } from './MarkdownViewer' +export type { MarkdownViewerProps } from './MarkdownViewer' diff --git a/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx b/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx new file mode 100644 index 0000000..6d948a8 --- /dev/null +++ b/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +export interface SquadBadgeProps extends React.HTMLAttributes { + /** Name of the squad to display */ + squadName: string + /** Additional CSS classes */ + className?: string +} + +/** + * SquadBadge - Displays the squad name in a subtle badge next to the logo. + * + * Uses mono font, uppercase, and muted styling to fit the command-center aesthetic. + * + * @example + * ```tsx + * + * ``` + */ +export const SquadBadge = forwardRef( + ({ squadName, className, ...props }, ref) => { + return ( + + {squadName} + + ) + } +) + +SquadBadge.displayName = 'SquadBadge' diff --git a/apps/web/src/components/molecules/SquadBadge/index.ts b/apps/web/src/components/molecules/SquadBadge/index.ts new file mode 100644 index 0000000..bbdecae --- /dev/null +++ b/apps/web/src/components/molecules/SquadBadge/index.ts @@ -0,0 +1 @@ +export { SquadBadge, type SquadBadgeProps } from './SquadBadge' diff --git a/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx b/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx new file mode 100644 index 0000000..cc46afd --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx @@ -0,0 +1,84 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { StatusFilterPill } from '@/components/molecules/StatusFilterPill' + +export interface StatusConfig { + /** Status key (e.g., "inbox", "in_progress") */ + status: string + /** Display label for the pill */ + label: string + /** Count of tasks with this status */ + count: number + /** Tailwind bg-class for the colored dot */ + color: string +} + +export interface StatusFilterBarProps extends React.HTMLAttributes { + /** Array of status configurations to render as filter pills */ + statuses: StatusConfig[] + /** Currently active status filter, or null for "All" */ + activeStatus: string | null + /** Callback when status filter changes. Passes null for "All". */ + onStatusChange: (status: string | null) => void +} + +/** + * StatusFilterBar - Container for status filter pills. + * + * Renders a horizontal row of StatusFilterPill components with an "All" pill + * prepended. Supports horizontal scrolling on small screens. + * + * @example + * ```tsx + * setFilter(status)} + * /> + * ``` + */ +export const StatusFilterBar = forwardRef( + ({ statuses, activeStatus, onStatusChange, className, ...props }, ref) => { + const totalCount = statuses.reduce((sum, s) => sum + s.count, 0) + + return ( +
    + {/* "All" pill */} + onStatusChange(null)} + /> + + {/* Status pills */} + {statuses.map((config) => ( + onStatusChange(config.status)} + /> + ))} +
    + ) + } +) + +StatusFilterBar.displayName = 'StatusFilterBar' diff --git a/apps/web/src/components/molecules/StatusFilterBar/index.ts b/apps/web/src/components/molecules/StatusFilterBar/index.ts new file mode 100644 index 0000000..282db84 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterBar/index.ts @@ -0,0 +1 @@ +export { StatusFilterBar, type StatusFilterBarProps, type StatusConfig } from './StatusFilterBar' diff --git a/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx b/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx new file mode 100644 index 0000000..19916c0 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx @@ -0,0 +1,63 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' + +export interface StatusFilterPillProps + extends Omit, 'color'> { + /** Display label for the pill (e.g., "All", "Inbox", "In Progress") */ + label: string + /** Count of items matching this filter */ + count: number + /** Tailwind bg-class for the colored dot (e.g., "bg-status-active") */ + color: string + /** Whether this pill is currently selected */ + isActive: boolean + /** Callback when the pill is clicked */ + onClick: () => void +} + +/** + * StatusFilterPill - Individual rounded pill button for filtering tasks by status. + * + * Displays a small colored dot, label text, and count badge. + * Selected state uses filled background; unselected is transparent with hover. + * + * @example + * ```tsx + * setFilter('in_progress')} + * /> + * ``` + */ +export const StatusFilterPill = forwardRef( + ({ label, count, color, isActive, onClick, className, ...props }, ref) => { + return ( + + ) + } +) + +StatusFilterPill.displayName = 'StatusFilterPill' diff --git a/apps/web/src/components/molecules/StatusFilterPill/index.ts b/apps/web/src/components/molecules/StatusFilterPill/index.ts new file mode 100644 index 0000000..5bc8bd4 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterPill/index.ts @@ -0,0 +1 @@ +export { StatusFilterPill, type StatusFilterPillProps } from './StatusFilterPill' diff --git a/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx b/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx new file mode 100644 index 0000000..e18dcca --- /dev/null +++ b/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx @@ -0,0 +1,70 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +type SquadStatus = 'active' | 'paused' + +export interface StatusToggleProps + extends Omit, 'children'> { + /** Current squad status */ + status: SquadStatus + /** Callback when the toggle is clicked */ + onToggle: () => void + /** Additional CSS classes */ + className?: string +} + +const statusStyles: Record = { + active: 'bg-status-active/15 text-status-active border-status-active/30', + paused: 'bg-status-offline/15 text-status-offline border-status-offline/30', +} + +const statusLabels: Record = { + active: 'ACTIVE', + paused: 'PAUSED', +} + +/** + * StatusToggle - Pill-shaped toggle button for squad status. + * + * Displays the current status (ACTIVE or PAUSED) with color-coded styling + * and toggles between states on click. + * + * @example + * ```tsx + * setStatus('paused')} /> + * setStatus('active')} disabled /> + * ``` + */ +export const StatusToggle = forwardRef( + ({ status, onToggle, disabled, className, ...props }, ref) => { + return ( + + ) + } +) + +StatusToggle.displayName = 'StatusToggle' diff --git a/apps/web/src/components/molecules/StatusToggle/index.ts b/apps/web/src/components/molecules/StatusToggle/index.ts new file mode 100644 index 0000000..def9c1b --- /dev/null +++ b/apps/web/src/components/molecules/StatusToggle/index.ts @@ -0,0 +1 @@ +export { StatusToggle, type StatusToggleProps } from './StatusToggle' diff --git a/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx b/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx new file mode 100644 index 0000000..c2c699f --- /dev/null +++ b/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx @@ -0,0 +1,86 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +/** + * Props for the SystemMessage component + */ +export interface SystemMessageProps { + /** The system message text */ + message: string + /** Optional timestamp for the message */ + timestamp?: string + /** Visual variant of the message */ + variant?: 'warning' | 'info' + /** Additional CSS classes */ + className?: string +} + +const variantStyles: Record<'warning' | 'info', string> = { + warning: 'border-l-2 border-status-blocked bg-status-blocked/5', + info: 'border-l-2 border-accent bg-accent/5', +} + +const variantIcons: Record<'warning' | 'info', 'AlertTriangle' | 'Info'> = { + warning: 'AlertTriangle', + info: 'Info', +} + +const variantIconColors: Record<'warning' | 'info', string> = { + warning: 'text-status-blocked', + info: 'text-accent', +} + +/** + * SystemMessage molecule component + * + * Displays a system-level message with special styling to distinguish + * it from regular activity entries. Supports warning and info variants. + * + * @example + * ```tsx + * + * ``` + */ +export const SystemMessage = forwardRef( + function SystemMessage({ message, timestamp, variant = 'info', className }, ref) { + const relativeTime = timestamp ? formatRelativeTime(timestamp) : null + + return ( +
    + +
    + + {message} + + {relativeTime && ( + + {relativeTime} + + )} +
    +
    + ) + } +) + +SystemMessage.displayName = 'SystemMessage' diff --git a/apps/web/src/components/molecules/SystemMessage/index.ts b/apps/web/src/components/molecules/SystemMessage/index.ts new file mode 100644 index 0000000..fd1503c --- /dev/null +++ b/apps/web/src/components/molecules/SystemMessage/index.ts @@ -0,0 +1 @@ +export { SystemMessage, type SystemMessageProps } from './SystemMessage' diff --git a/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx new file mode 100644 index 0000000..c100774 --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TaskCardCompact } from './TaskCardCompact' +import type { TaskData } from '@/components/organisms/KanbanColumn' + +describe('TaskCardCompact', () => { + const mockTask: TaskData = { + id: 'task-1', + title: 'Fix login bug', + status: 'in_progress', + priority: 'urgent', + position: 0, + } + + it('renders the task title', () => { + render() + expect(screen.getByText('Fix login bug')).toBeInTheDocument() + }) + + it('renders with correct test id', () => { + render() + expect(screen.getByTestId('task-compact-task-1')).toBeInTheDocument() + }) + + it('renders priority dot with urgent color', () => { + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-priority-urgent') + }) + + it('renders priority dot with high color', () => { + const highTask = { ...mockTask, priority: 'high' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-priority-high') + }) + + it('renders priority dot with normal color', () => { + const normalTask = { ...mockTask, priority: 'normal' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-accent') + }) + + it('renders priority dot with low color', () => { + const lowTask = { ...mockTask, priority: 'low' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-text-muted') + }) + + it('calls onClick when clicked', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + await user.click(screen.getByTestId('task-compact-task-1')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Enter key', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + const card = screen.getByTestId('task-compact-task-1') + card.focus() + await user.keyboard('{Enter}') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Space key', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + const card = screen.getByTestId('task-compact-task-1') + card.focus() + await user.keyboard(' ') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('has button role for accessibility', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute('role', 'button') + }) + + it('has descriptive aria-label', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute( + 'aria-label', + 'Fix login bug, priority urgent' + ) + }) + + it('is keyboard focusable', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute('tabIndex', '0') + }) + + it('applies custom className', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveClass('custom-class') + }) + + it('has hover styles', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveClass('hover:bg-background-elevated') + }) +}) diff --git a/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx new file mode 100644 index 0000000..ee9d60b --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx @@ -0,0 +1,103 @@ +import { forwardRef, useCallback } from 'react' + +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import type { TaskData, TaskPriority } from '@/components/organisms/KanbanColumn' + +/** + * Props for the TaskCardCompact component + */ +export interface TaskCardCompactProps + extends Omit, 'onClick'> { + /** The task data to display */ + task: TaskData + /** Callback when the compact card is clicked */ + onClick?: () => void +} + +/** + * Returns the Tailwind background class for a priority dot indicator + */ +function getPriorityDotColor(priority: TaskPriority): string { + const colors: Record = { + urgent: 'bg-priority-urgent', + high: 'bg-priority-high', + normal: 'bg-accent', + low: 'bg-text-muted', + } + return colors[priority] +} + +/** + * TaskCardCompact molecule component + * + * A single-line compact task row for list view mode. Shows a priority + * dot indicator, truncated title, and a chevron for navigation. + * + * @example + * ```tsx + * openTaskDetail('1')} + * /> + * ``` + */ +export const TaskCardCompact = forwardRef( + ({ task, onClick, className, ...props }, ref) => { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick?.() + } + }, + [onClick] + ) + + return ( +
    + {/* Priority dot */} +
    + ) + } +) + +TaskCardCompact.displayName = 'TaskCardCompact' diff --git a/apps/web/src/components/molecules/TaskCardCompact/index.ts b/apps/web/src/components/molecules/TaskCardCompact/index.ts new file mode 100644 index 0000000..2a179f7 --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/index.ts @@ -0,0 +1,2 @@ +export { TaskCardCompact } from './TaskCardCompact' +export type { TaskCardCompactProps } from './TaskCardCompact' diff --git a/apps/web/src/components/molecules/index.ts b/apps/web/src/components/molecules/index.ts index 13ea644..8f770f6 100644 --- a/apps/web/src/components/molecules/index.ts +++ b/apps/web/src/components/molecules/index.ts @@ -13,6 +13,8 @@ * - TaskMeta: Task metadata display (assignees, priority, timestamp) * - UnreadMentions: Badge showing count of unread @mentions * - ViewToggle: Segmented control for switching between view modes + * - MarkdownViewer: Rich markdown content renderer with design tokens + * - DeliverableView: Document viewer wrapping MarkdownViewer with navigation */ export { @@ -63,3 +65,25 @@ export { type UnreadMentionsIndicatorProps, } from './UnreadMentions' export { ViewToggle, type ViewToggleProps, type ViewMode } from './ViewToggle' +export { AllAgentsRow, type AllAgentsRowProps } from './AllAgentsRow' +export { AgentListItem, type AgentListItemProps } from './AgentListItem' +export { StatusFilterPill, type StatusFilterPillProps } from './StatusFilterPill' +export { + StatusFilterBar, + type StatusFilterBarProps, + type StatusConfig, +} from './StatusFilterBar' +export { + AssigneeDisplay, + type AssigneeDisplayProps, + type AssigneeDisplayData, +} from './AssigneeDisplay' +export { TaskCardCompact, type TaskCardCompactProps } from './TaskCardCompact' +export { AgentFilterChips, type AgentFilterChipsProps } from './AgentFilterChips' +export { FeedTypeTabs, type FeedTypeTabsProps, type FeedType } from './FeedTypeTabs' +export { FeedEntry, type FeedEntryProps } from './FeedEntry' +export { SystemMessage, type SystemMessageProps } from './SystemMessage' +export { AgentStatusBox, type AgentStatusBoxProps } from './AgentStatusBox' +export { AttentionList, type AttentionListProps, type AttentionItem } from './AttentionList' +export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer' +export { DeliverableView, type DeliverableViewProps, type DeliverableDocument } from './DeliverableView' diff --git a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx index 36e0cd6..9194df8 100644 --- a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx +++ b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx @@ -23,10 +23,10 @@ describe('AgentSidebar', () => { expect(screen.getByText('AGENTS')).toBeInTheDocument() }) - it('renders agent count badge', () => { + it('renders total agent count badge', () => { render() - expect(screen.getByText('1/4 active')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() }) it('renders all agents', () => { @@ -37,14 +37,121 @@ describe('AgentSidebar', () => { expect(screen.getByText('Research Bot')).toBeInTheDocument() expect(screen.getByText('Offline Agent')).toBeInTheDocument() }) + }) + + describe('AllAgentsRow', () => { + it('renders AllAgentsRow with correct counts', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toBeInTheDocument() + expect(screen.getByText('All Agents')).toBeInTheDocument() + expect(screen.getByText('4 total')).toBeInTheDocument() + expect(screen.getByText('1 ACTIVE')).toBeInTheDocument() + }) + + it('selects AllAgentsRow when no agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'true') + expect(allAgentsRow).toHaveClass('bg-accent/10') + }) + + it('deselects AllAgentsRow when an agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'false') + expect(allAgentsRow).not.toHaveClass('bg-accent/10') + }) + + it('calls onAllAgentsClick when AllAgentsRow is clicked', async () => { + const user = userEvent.setup() + const handleAllAgentsClick = vi.fn() + render( + + ) + + await user.click(screen.getByTestId('all-agents-row')) + + expect(handleAllAgentsClick).toHaveBeenCalledOnce() + }) + }) + + describe('AgentListItem role badges', () => { + it('renders INT badge for writer roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('INT') + }) + + it('renders INT badge for editor roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-2') + expect(roleBadge).toHaveTextContent('INT') + }) + + it('renders SPC badge for specialist roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-3') + expect(roleBadge).toHaveTextContent('SPC') + }) + + it('renders LEAD badge for lead roles', () => { + const leadAgents: AgentData[] = [ + { id: '1', name: 'Team Lead', role: 'Squad Lead', status: 'active' }, + ] + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('LEAD') + }) + + it('renders LEAD badge for coordinator roles', () => { + const coordAgents: AgentData[] = [ + { id: '1', name: 'Coordinator Bot', role: 'Task Coordinator', status: 'idle' }, + ] + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('LEAD') + }) + }) + + describe('AgentListItem status text', () => { + it('renders WORKING for active agents', () => { + render() + + const statusText = screen.getByTestId('status-text-1') + expect(statusText).toHaveTextContent('WORKING') + }) + + it('renders IDLE for idle agents', () => { + render() + + const statusText = screen.getByTestId('status-text-2') + expect(statusText).toHaveTextContent('IDLE') + }) - it('renders agent roles', () => { + it('renders BLOCKED for blocked agents', () => { render() - expect(screen.getByText('Writer')).toBeInTheDocument() - expect(screen.getByText('Editor')).toBeInTheDocument() - expect(screen.getByText('Researcher')).toBeInTheDocument() - expect(screen.getByText('Helper')).toBeInTheDocument() + const statusText = screen.getByTestId('status-text-3') + expect(statusText).toHaveTextContent('BLOCKED') + }) + + it('renders OFFLINE for offline agents', () => { + render() + + const statusText = screen.getByTestId('status-text-4') + expect(statusText).toHaveTextContent('OFFLINE') }) }) @@ -62,10 +169,10 @@ describe('AgentSidebar', () => { expect(screen.getByText('AGENTS')).toBeInTheDocument() }) - it('does not show count badge when loading', () => { + it('does not show AllAgentsRow when loading', () => { render() - expect(screen.queryByText(/active/)).not.toBeInTheDocument() + expect(screen.queryByTestId('all-agents-row')).not.toBeInTheDocument() }) }) @@ -144,6 +251,13 @@ describe('AgentSidebar', () => { const agentButton = screen.getByTestId('agent-Content-Writer') expect(agentButton).toHaveAttribute('aria-pressed', 'true') }) + + it('AllAgentsRow is deselected when an agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'false') + }) }) describe('click handling', () => { @@ -176,6 +290,14 @@ describe('AgentSidebar', () => { agentButton.focus() expect(document.activeElement).toBe(agentButton) }) + + it('AllAgentsRow is focusable', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + allAgentsRow.focus() + expect(document.activeElement).toBe(allAgentsRow) + }) }) describe('accessibility', () => { @@ -198,7 +320,16 @@ describe('AgentSidebar', () => { const agentButton = screen.getByTestId('agent-Content-Writer') expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('Content Writer')) - expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('Active')) + expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('WORKING')) + }) + + it('AllAgentsRow has descriptive aria-label', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('All Agents')) + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('4 total')) + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('1 active')) }) it('renders as list structure', () => { @@ -239,14 +370,17 @@ describe('AgentSidebar', () => { const singleAgent = [mockAgents[0]] render() - expect(screen.getByText('1/1 active')).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('1 total')).toBeInTheDocument() + expect(screen.getByText('1 ACTIVE')).toBeInTheDocument() }) it('handles all agents inactive', () => { const inactiveAgents = mockAgents.map(a => ({ ...a, status: 'idle' as const })) render() - expect(screen.getByText('0/4 active')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('0 ACTIVE')).toBeInTheDocument() }) it('handles agent name with spaces', () => { diff --git a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx index 43bafd8..d90e0ac 100644 --- a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx +++ b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx @@ -1,6 +1,8 @@ 'use client' -import { Avatar, Badge, Icon, StatusDot, Text } from '@/components/atoms' +import { Badge, Icon, Text } from '@/components/atoms' +import { AgentListItem } from '@/components/molecules/AgentListItem' +import { AllAgentsRow } from '@/components/molecules/AllAgentsRow' import { cn } from '@/lib/utils' /** @@ -33,67 +35,20 @@ export interface AgentSidebarProps { selectedAgentId?: string | null /** Callback when an agent is clicked */ onAgentClick?: (agent: AgentData) => void + /** Callback when the All Agents row is clicked */ + onAllAgentsClick?: () => void /** Whether the sidebar is in a loading state */ isLoading?: boolean /** Additional CSS classes */ className?: string } -/** - * Maps agent status to StatusDot status - * Note: StatusDot doesn't support 'blocked', so we map it to 'offline' - */ -function getStatusDotStatus( - status: AgentData['status'] -): 'active' | 'idle' | 'offline' { - if (status === 'blocked') return 'offline' - return status -} - -/** - * Gets a human-readable label for the agent status - */ -function getStatusLabel(status: AgentData['status']): string { - switch (status) { - case 'active': - return 'Active' - case 'idle': - return 'Idle' - case 'blocked': - return 'Blocked' - case 'offline': - return 'Offline' - default: - return 'Unknown' - } -} - -/** - * Maps avatar color strings to Avatar color props - */ -function getAvatarColor( - color?: string -): 'accent' | 'blue' | 'green' | 'purple' | 'orange' | 'pink' | 'teal' { - const validColors = [ - 'accent', - 'blue', - 'green', - 'purple', - 'orange', - 'pink', - 'teal', - ] as const - if (color && validColors.includes(color as (typeof validColors)[number])) { - return color as (typeof validColors)[number] - } - return 'accent' -} - /** * AgentSidebar organism component * * Displays a list of agents with their status indicators in the dashboard sidebar. - * Supports agent selection and shows visual feedback for the selected agent. + * Includes an "All Agents" summary row at the top and individual agent rows with + * role badges, status text labels, and selection state. * * @example * ```tsx @@ -104,6 +59,7 @@ function getAvatarColor( * ]} * selectedAgentId="1" * onAgentClick={(agent) => console.log('Selected:', agent.name)} + * onAllAgentsClick={() => console.log('All agents selected')} * /> * ``` */ @@ -111,6 +67,7 @@ export function AgentSidebar({ agents, selectedAgentId, onAgentClick, + onAllAgentsClick, isLoading = false, className, }: AgentSidebarProps) { @@ -187,108 +144,29 @@ export function AgentSidebar({ AGENTS - {activeCount}/{agents.length} active + {agents.length}
    + {/* All Agents Row */} + onAllAgentsClick?.()} + /> + {/* Agent list */}
      - {agents.map((agent) => { - const isSelected = selectedAgentId === agent.id - const statusDotStatus = getStatusDotStatus(agent.status) - - return ( -
    • - -
    • - ) - })} + {agents.map((agent) => ( +
    • + onAgentClick?.(agent)} + /> +
    • + ))}
    ) diff --git a/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx b/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx index 7825c3c..1f6501b 100644 --- a/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx +++ b/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx @@ -2,12 +2,15 @@ import * as React from 'react' +import { formatRelativeTime } from '@/lib/formatRelativeTime' import { Text } from '@/components/atoms' +import { AttentionList } from '@/components/molecules/AttentionList' import { AgentSidebar } from '@/components/organisms/AgentSidebar' import type { AgentData } from '@/components/organisms/AgentSidebar' import { AgentProfilePanel } from '@/components/templates' import type { AgentProfileData } from '@/components/templates' -import { createClient } from '@/lib/supabase/client' +import { useAgentAttention } from '@/hooks/useAgentAttention' +import { createClient } from '@/lib/supabase/browser' import { cn } from '@/lib/utils' import type { Database } from '@mission-control/database' @@ -41,26 +44,6 @@ function getActivityTypeLabel(type: ActivityRow['type']): string { return labels[type] ?? 'Activity' } -/** - * Formats a timestamp as relative time (e.g., "2h ago", "5m ago") - */ -function formatRelativeTime(timestamp: string | null): string { - if (!timestamp) return '' - - const now = new Date() - const date = new Date(timestamp) - const diffMs = now.getTime() - date.getTime() - const diffSeconds = Math.floor(diffMs / 1000) - const diffMinutes = Math.floor(diffSeconds / 60) - const diffHours = Math.floor(diffMinutes / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffDays > 0) return `${diffDays}d ago` - if (diffHours > 0) return `${diffHours}h ago` - if (diffMinutes > 0) return `${diffMinutes}m ago` - return 'just now' -} - /** * Timeline content component showing agent activities */ @@ -169,6 +152,11 @@ export function AgentSidebarWithPanel({ const [messages, setMessages] = React.useState([]) const [isLoading, setIsLoading] = React.useState(false) + // Fetch attention items for selected agent + const { items: attentionItems, totalCount: attentionCount } = useAgentAttention( + selectedAgent?.id ?? null + ) + /** * Handles agent click from sidebar - selects the agent and opens the panel */ @@ -177,6 +165,16 @@ export function AgentSidebarWithPanel({ setIsPanelOpen(true) }, []) + /** + * Handles All Agents click - deselects current agent + */ + const handleAllAgentsClick = React.useCallback(() => { + setSelectedAgent(null) + setIsPanelOpen(false) + setActivities([]) + setMessages([]) + }, []) + /** * Handles panel close - resets selection */ @@ -259,12 +257,36 @@ export function AgentSidebarWithPanel({ } }, [selectedAgent, agentProfiles]) + /** + * Handles sending a direct message to the selected agent + */ + const handleSendMessage = React.useCallback(async (message: string) => { + if (!selectedAgent) return + const supabase = createClient() + const { error } = await supabase.from('direct_messages').insert({ + agent_id: selectedAgent.id, + content: message, + from_human: true, + }) + if (!error) { + // Re-fetch messages to show the new one + const { data: messagesData } = await supabase + .from('direct_messages') + .select('*') + .eq('agent_id', selectedAgent.id) + .order('created_at', { ascending: true }) + .limit(50) + if (messagesData) setMessages(messagesData) + } + }, [selectedAgent]) + return (
    } + attentionContent={} + attentionCount={attentionCount} + onSendMessage={handleSendMessage} />
    ) diff --git a/apps/web/src/components/organisms/Header/Header.test.tsx b/apps/web/src/components/organisms/Header/Header.test.tsx index d304100..8047d35 100644 --- a/apps/web/src/components/organisms/Header/Header.test.tsx +++ b/apps/web/src/components/organisms/Header/Header.test.tsx @@ -1,152 +1,222 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Header } from './Header' -// Mock next/link to avoid router issues in tests -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( - {children} - ), +// Mock next/navigation to keep tests safe from router context +vi.mock('next/navigation', () => ({ + usePathname: () => '/dashboard', })) describe('Header', () => { describe('basic rendering', () => { - it('renders header element', () => { + it('renders header element with data-testid', () => { render(
    ) - expect(screen.getByTestId('header')).toBeInTheDocument() + const header = screen.getByTestId('header') + expect(header).toBeInTheDocument() + expect(header.tagName).toBe('HEADER') }) - it('renders logo section', () => { + it('shows diamond logo icon', () => { render(
    ) - expect(screen.getByTestId('header-logo')).toBeInTheDocument() + expect(screen.getByTestId('header-logo-icon')).toBeInTheDocument() }) - it('renders MC badge in logo', () => { + it('shows MISSION CONTROL title', () => { render(
    ) - expect(screen.getByText('MC')).toBeInTheDocument() + expect(screen.getByTestId('header-title')).toHaveTextContent( + 'MISSION CONTROL' + ) }) + }) - it('logo links to home', () => { - render(
    ) + describe('squad badge', () => { + it('shows squad badge when squadName is provided', () => { + render(
    ) - const link = screen.getByTestId('header-logo').querySelector('a') - expect(link).toHaveAttribute('href', '/') + expect(screen.getByTestId('squad-badge')).toBeInTheDocument() + expect(screen.getByText('Alpha Team')).toBeInTheDocument() }) - }) - describe('stats display', () => { - it('renders stats section', () => { + it('does not show squad badge when squadName is not provided', () => { render(
    ) - expect(screen.getByTestId('header-stats')).toBeInTheDocument() + expect(screen.queryByTestId('squad-badge')).not.toBeInTheDocument() }) + }) - it('displays agent counts', () => { - render(
    ) + describe('stats display', () => { + it('shows agents active stat', () => { + render(
    ) expect(screen.getByText('3')).toBeInTheDocument() - expect(screen.getByText('/5')).toBeInTheDocument() - expect(screen.getByText(/AGENTS ACTIVE/)).toBeInTheDocument() + expect(screen.getByText('AGENTS ACTIVE')).toBeInTheDocument() }) - it('displays pending tasks count', () => { + it('shows tasks in queue stat', () => { render(
    ) expect(screen.getByText('12')).toBeInTheDocument() - expect(screen.getByText(/PENDING/)).toBeInTheDocument() + expect(screen.getByText('TASKS IN QUEUE')).toBeInTheDocument() }) it('defaults to zero counts', () => { render(
    ) const zeros = screen.getAllByText('0') - expect(zeros.length).toBeGreaterThanOrEqual(1) + expect(zeros).toHaveLength(2) }) - }) - describe('clock placeholder', () => { - it('renders clock section', () => { + it('renders stats section with data-testid', () => { render(
    ) - expect(screen.getByTestId('header-clock')).toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() }) + }) - it('displays placeholder time', () => { + describe('status toggle', () => { + it('shows StatusToggle with active status by default', () => { render(
    ) - expect(screen.getByText('00:00:00')).toBeInTheDocument() + const toggle = screen.getByTestId('status-toggle') + expect(toggle).toBeInTheDocument() + expect(toggle).toHaveTextContent('ACTIVE') + }) + + it('shows StatusToggle with paused status', () => { + render(
    ) + + const toggle = screen.getByTestId('status-toggle') + expect(toggle).toHaveTextContent('PAUSED') + }) + + it('calls onSquadStatusToggle when clicked', async () => { + const user = userEvent.setup() + const handleToggle = vi.fn() + render( +
    + ) + + await user.click(screen.getByTestId('status-toggle')) + expect(handleToggle).toHaveBeenCalledTimes(1) }) }) - describe('docs button', () => { - it('renders docs button', () => { + describe('action buttons', () => { + it('renders chat button', () => { render(
    ) - expect(screen.getByTestId('header-docs-button')).toBeInTheDocument() + expect(screen.getByTestId('header-chat-button')).toBeInTheDocument() + expect(screen.getByLabelText('Open chat')).toBeInTheDocument() }) - it('docs button links to /docs by default', () => { + it('renders broadcast button', () => { render(
    ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('href', '/docs') + expect( + screen.getByTestId('header-broadcast-button') + ).toBeInTheDocument() + expect(screen.getByLabelText('Send broadcast')).toBeInTheDocument() }) - it('docs button uses custom URL when provided', () => { - render(
    ) + it('renders docs button', () => { + render(
    ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('href', '/custom-docs') + expect(screen.getByTestId('header-docs-button')).toBeInTheDocument() + expect(screen.getByLabelText('Open documentation')).toBeInTheDocument() }) - it('docs link opens in new tab', () => { - render(
    ) + it('calls action handlers when clicked', async () => { + const user = userEvent.setup() + const onChat = vi.fn() + const onBroadcast = vi.fn() + const onDocs = vi.fn() + + render( +
    + ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + await user.click(screen.getByTestId('header-chat-button')) + expect(onChat).toHaveBeenCalledTimes(1) + + await user.click(screen.getByTestId('header-broadcast-button')) + expect(onBroadcast).toHaveBeenCalledTimes(1) + + await user.click(screen.getByTestId('header-docs-button')) + expect(onDocs).toHaveBeenCalledTimes(1) }) + }) - it('has accessible label', () => { + describe('clock', () => { + it('renders Clock component', () => { render(
    ) - expect(screen.getByLabelText('Open documentation')).toBeInTheDocument() + expect(screen.getByTestId('clock')).toBeInTheDocument() }) }) - describe('focus indicator', () => { - it('renders focus indicator when focusedAgent is provided', () => { - render(
    ) + describe('connection status', () => { + it('shows connected state by default', () => { + render(
    ) - expect(screen.getByTestId('header-focus-indicator')).toBeInTheDocument() + expect(screen.getByTestId('connection-status')).toBeInTheDocument() + expect(screen.getByText('ONLINE')).toBeInTheDocument() }) - it('displays agent name in focus indicator', () => { - render(
    ) + it('shows disconnected state', () => { + render(
    ) - expect(screen.getByText(/IN FOCUS:/)).toBeInTheDocument() - expect(screen.getByText(/Content Writer/)).toBeInTheDocument() + expect(screen.getByText('OFFLINE')).toBeInTheDocument() }) + }) - it('does not render focus indicator when focusedAgent is null', () => { - render(
    ) + describe('filter indicator', () => { + it('shows FilterIndicator when filteredAgentName is set', () => { + render( +
    + ) - expect(screen.queryByTestId('header-focus-indicator')).not.toBeInTheDocument() + expect(screen.getByTestId('filter-indicator')).toBeInTheDocument() + expect(screen.getByText('Content Writer')).toBeInTheDocument() + expect(screen.getByText('5 TASKS')).toBeInTheDocument() }) - it('does not render focus indicator when focusedAgent is not provided', () => { - render(
    ) + it('FilterIndicator replaces stats display', () => { + render( +
    + ) - expect(screen.queryByTestId('header-focus-indicator')).not.toBeInTheDocument() + expect(screen.getByTestId('filter-indicator')).toBeInTheDocument() + expect(screen.queryByTestId('header-stats')).not.toBeInTheDocument() }) - it('focus indicator has accent styling', () => { - render(
    ) + it('shows stats when filteredAgentName is null', () => { + render(
    ) - const indicator = screen.getByTestId('header-focus-indicator') - expect(indicator).toHaveClass('border-accent/30', 'bg-accent/10') + expect( + screen.queryByTestId('filter-indicator') + ).not.toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() + }) + + it('shows stats when filteredAgentName is not provided', () => { + render(
    ) + + expect( + screen.queryByTestId('filter-indicator') + ).not.toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() }) }) @@ -158,13 +228,6 @@ describe('Header', () => { expect(header).toHaveClass('custom-class') }) - it('header is rendered as header element', () => { - render(
    ) - - const header = screen.getByTestId('header') - expect(header.tagName).toBe('HEADER') - }) - it('has full width and padding', () => { render(
    ) @@ -179,19 +242,4 @@ describe('Header', () => { expect(header).toHaveClass('flex', 'items-center', 'justify-between') }) }) - - describe('edge cases', () => { - it('handles large agent counts', () => { - render(
    ) - - expect(screen.getByText('999')).toBeInTheDocument() - expect(screen.getByText('/1000')).toBeInTheDocument() - }) - - it('handles long agent names', () => { - render(
    ) - - expect(screen.getByText(/Super Long Agent Name That Could Overflow/)).toBeInTheDocument() - }) - }) }) diff --git a/apps/web/src/components/organisms/Header/Header.tsx b/apps/web/src/components/organisms/Header/Header.tsx index 1f67f93..f9e2815 100644 --- a/apps/web/src/components/organisms/Header/Header.tsx +++ b/apps/web/src/components/organisms/Header/Header.tsx @@ -1,167 +1,198 @@ 'use client' -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { LayoutDashboard, Users, Bot, ListTodo, Plus } from 'lucide-react' -import { Badge, Button, Icon } from '@/components/atoms' +import { forwardRef } from 'react' +import { Diamond, MessageSquare, Megaphone, BookOpen } from 'lucide-react' +import { Button } from '@/components/atoms' +import { Clock } from '@/components/molecules/Clock' +import { SquadBadge } from '@/components/molecules/SquadBadge' +import { StatusToggle } from '@/components/molecules/StatusToggle' +import { ConnectionStatus } from '@/components/molecules/ConnectionStatus' +import { FilterIndicator } from '@/components/molecules/FilterIndicator' import { cn } from '@/lib/utils' -const navItems = [ - { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, - { href: '/squads', label: 'Squads', icon: Users }, - { href: '/agents', label: 'Agents', icon: Bot }, - { href: '/tasks', label: 'Tasks', icon: ListTodo }, -] as const - export interface HeaderProps { - /** Name of currently focused agent (shows "IN FOCUS: [Agent]" display) */ - focusedAgent?: string | null - /** Number of active agents for stats display */ + /** Name of the current squad */ + squadName?: string + /** Current squad operational status */ + squadStatus?: 'active' | 'paused' + /** Callback when squad status toggle is clicked */ + onSquadStatusToggle?: () => void + /** Number of currently active agents */ activeAgentsCount?: number - /** Total number of agents for stats display */ + /** Total number of agents in the squad */ totalAgentsCount?: number - /** Number of pending tasks */ + /** Number of tasks currently in the queue */ pendingTasksCount?: number - /** URL for docs button */ - docsUrl?: string + /** Callback when the chat action button is clicked */ + onChatClick?: () => void + /** Callback when the broadcast action button is clicked */ + onBroadcastClick?: () => void + /** Callback when the docs action button is clicked */ + onDocsClick?: () => void + /** Whether the dashboard is connected to the backend */ + isConnected?: boolean + /** When set, replaces the stats display with a filter indicator */ + filteredAgentName?: string | null + /** Number of tasks for the filtered agent */ + filteredTaskCount?: number /** Additional CSS classes */ className?: string } /** - * Header organism component for the Mission Control dashboard. + * Header organism component for the Mission Control command-center dashboard. * - * Displays: - * - Logo ("MC" badge + "Mission Control" text) - * - Stats badges showing active agents and pending tasks - * - Clock placeholder area (Clock molecule will be task 2.1.6) - * - Docs button linking to documentation - * - Optional "IN FOCUS: [Agent]" display + * Displays a horizontal status bar with: + * - Logo (Diamond icon) + "MISSION CONTROL" branding + optional squad badge + * - Center: Large stat numbers (agents active, tasks in queue) or filter indicator + * - Status toggle pill (ACTIVE / PAUSED) + * - Action buttons (Chat, Broadcast, Docs) + * - Clock with date + * - Connection status indicator * * @example * ```tsx *
    toggleStatus()} * activeAgentsCount={3} * totalAgentsCount={5} * pendingTasksCount={12} - * focusedAgent="Content Writer" + * isConnected={true} * /> * ``` */ -export function Header({ - focusedAgent, - activeAgentsCount = 0, - totalAgentsCount = 0, - pendingTasksCount = 0, - docsUrl = '/docs', - className, -}: HeaderProps) { - const pathname = usePathname() - - return ( -
    - {/* Left section: Logo and Navigation */} -
    - -
    - MC -
    - - - {/* Navigation */} - -
    - - {/* Center section: Stats and Clock */} -
    - {/* Stats Area */} -
    - - {activeAgentsCount} - /{totalAgentsCount} - {' AGENTS ACTIVE'} - - - {pendingTasksCount} - {' PENDING'} - + MISSION CONTROL + + {squadName && }
    - {/* Clock Area - placeholder for Clock molecule (task 2.1.6) */} + {/* Center section: Stats or Filter Indicator */}
    - - - 00:00:00 - + {filteredAgentName ? ( + + ) : ( +
    +
    + + {activeAgentsCount} + + + AGENTS ACTIVE + +
    +
    + + {pendingTasksCount} + + + TASKS IN QUEUE + +
    +
    + )}
    -
    - {/* Right section: Docs button and Focus indicator */} -
    - {/* Docs Button */} - - - + {/* Right section: Status toggle + Actions + Clock + Connection */} +
    + {})} + disabled={!onSquadStatusToggle} + /> - {/* Focus Indicator */} - {focusedAgent && ( + {/* Action buttons */}
    - - - IN FOCUS: {focusedAgent} - + + +
    - )} -
    -
    - ) -} + + + + + +
    + ) + } +) + +Header.displayName = 'Header' diff --git a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx index 7b509e4..0772636 100644 --- a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx +++ b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx @@ -37,10 +37,10 @@ describe('KanbanColumn', () => { expect(screen.getByText('INBOX')).toBeInTheDocument() }) - it('renders task count badge', () => { + it('renders task count in parentheses', () => { render() - expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('(3)')).toBeInTheDocument() }) it('renders all tasks', () => { @@ -202,10 +202,10 @@ describe('KanbanColumn', () => { expect(column).toHaveAttribute('aria-label', 'INBOX column, 3 tasks') }) - it('task count badge has aria-label', () => { + it('renders task count text', () => { render() - expect(screen.getByLabelText('3 tasks')).toBeInTheDocument() + expect(screen.getByText('(3)')).toBeInTheDocument() }) }) diff --git a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx index 78635a3..12fb70e 100644 --- a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx +++ b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx @@ -4,7 +4,7 @@ import type { ComponentProps, ReactNode } from 'react' import { useMemo } from 'react' import { cn } from '@/lib/utils' -import { Badge, Icon, Text } from '@/components/atoms' +import { Icon, Text } from '@/components/atoms' type IconName = ComponentProps['name'] @@ -60,6 +60,8 @@ export interface TaskData { updated_at?: string | null /** Whether the task is blocked */ isBlocked?: boolean + /** Optional tags for categorization */ + tags?: string[] } /** @@ -164,27 +166,18 @@ export function KanbanColumn({ {/* Column header */}
    -
    -
    - - {tasks.length} - +
    {/* Task list with drop zone support */} diff --git a/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx b/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx index ae5b670..8aa94cd 100644 --- a/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx +++ b/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LiveFeed, type ActivityData } from './LiveFeed' @@ -14,6 +14,7 @@ describe('LiveFeed', () => { { id: '1', squad_id: 'squad-1', + agent_id: 'agent-1', type: 'task_created', message: 'created a new task', agent_name: 'Content Writer', @@ -23,6 +24,7 @@ describe('LiveFeed', () => { { id: '2', squad_id: 'squad-1', + agent_id: 'agent-2', type: 'task_status_changed', message: 'moved task to in progress', agent_name: 'Editor', @@ -32,12 +34,18 @@ describe('LiveFeed', () => { { id: '3', squad_id: 'squad-1', + agent_id: 'agent-1', type: 'message_sent', message: 'sent a message', created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago }, ] + const mockAgents = [ + { id: 'agent-1', name: 'Content Writer' }, + { id: 'agent-2', name: 'Editor' }, + ] + describe('basic rendering', () => { it('renders feed container', () => { render() @@ -45,10 +53,10 @@ describe('LiveFeed', () => { expect(screen.getByTestId('live-feed')).toBeInTheDocument() }) - it('renders ACTIVITY header', () => { + it('renders LIVE FEED header', () => { render() - expect(screen.getByText('ACTIVITY')).toBeInTheDocument() + expect(screen.getByText('LIVE FEED')).toBeInTheDocument() }) it('renders all activities', () => { @@ -81,6 +89,195 @@ describe('LiveFeed', () => { expect(screen.getByText('30m ago')).toBeInTheDocument() expect(screen.getByText('2h ago')).toBeInTheDocument() }) + + it('renders FeedTypeTabs', () => { + render() + + expect(screen.getByTestId('feed-type-tabs')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-all')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-tasks')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-comments')).toBeInTheDocument() + }) + + it('renders StatusDot in header', () => { + render() + + // StatusDot has role="status" + const statusDots = screen.getAllByRole('status') + expect(statusDots.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('agent filter chips', () => { + it('renders AgentFilterChips when agents prop is provided', () => { + render() + + expect(screen.getByTestId('agent-filter-chips')).toBeInTheDocument() + }) + + it('does not render AgentFilterChips when agents prop is not provided', () => { + render() + + expect(screen.queryByTestId('agent-filter-chips')).not.toBeInTheDocument() + }) + + it('renders All chip and agent chips', () => { + render() + + expect(screen.getByTestId('agent-chip-all')).toBeInTheDocument() + expect(screen.getByTestId('agent-chip-agent-1')).toBeInTheDocument() + expect(screen.getByTestId('agent-chip-agent-2')).toBeInTheDocument() + }) + + it('filters activities when an agent chip is clicked', async () => { + const user = userEvent.setup() + render() + + // Click on Content Writer chip (agent-1) + await user.click(screen.getByTestId('agent-chip-agent-1')) + + // Activities by agent-1 should remain visible + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + + // Activity by agent-2 should be filtered out + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() + }) + + it('shows all activities when All chip is clicked after filtering', async () => { + const user = userEvent.setup() + render() + + // Filter by agent + await user.click(screen.getByTestId('agent-chip-agent-2')) + expect(screen.queryByTestId('activity-1')).not.toBeInTheDocument() + + // Reset by clicking All + await user.click(screen.getByTestId('agent-chip-all')) + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-2')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + }) + }) + + describe('feed type tabs', () => { + it('renders all type tabs', () => { + render() + + expect(screen.getByTestId('feed-tab-all')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-tasks')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-comments')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-docs')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-status')).toBeInTheDocument() + }) + + it('filters activities by type when a tab is clicked', async () => { + const user = userEvent.setup() + render() + + // Click "Comments" tab - should only show message_sent activities + await user.click(screen.getByTestId('feed-tab-comments')) + + expect(screen.getByTestId('activity-3')).toBeInTheDocument() // message_sent + expect(screen.queryByTestId('activity-1')).not.toBeInTheDocument() // task_created + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() // task_status_changed + }) + + it('filters activities by tasks type', async () => { + const user = userEvent.setup() + render() + + // Click "Tasks" tab + await user.click(screen.getByTestId('feed-tab-tasks')) + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() // task_created + expect(screen.getByTestId('activity-2')).toBeInTheDocument() // task_status_changed + expect(screen.queryByTestId('activity-3')).not.toBeInTheDocument() // message_sent + }) + + it('shows all activities when All tab is clicked', async () => { + const user = userEvent.setup() + render() + + // Filter then reset + await user.click(screen.getByTestId('feed-tab-comments')) + await user.click(screen.getByTestId('feed-tab-all')) + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-2')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + }) + + it('shows empty filter message when no activities match', async () => { + const user = userEvent.setup() + render() + + // Click "Docs" tab - no document_created activities in mock data + await user.click(screen.getByTestId('feed-tab-docs')) + + expect(screen.getByText('No activities match the current filters')).toBeInTheDocument() + }) + + it('displays type counts in tabs', () => { + render() + + // "All" tab should show total count + const allTab = screen.getByTestId('feed-tab-all') + expect(within(allTab).getByText('(3)')).toBeInTheDocument() + }) + }) + + describe('combined filtering', () => { + it('filters by both agent and type simultaneously', async () => { + const user = userEvent.setup() + + const activities: ActivityData[] = [ + { + id: '1', + squad_id: 's1', + agent_id: 'agent-1', + type: 'task_created', + message: 'task by writer', + agent_name: 'Writer', + created_at: new Date().toISOString(), + }, + { + id: '2', + squad_id: 's1', + agent_id: 'agent-1', + type: 'message_sent', + message: 'comment by writer', + agent_name: 'Writer', + created_at: new Date().toISOString(), + }, + { + id: '3', + squad_id: 's1', + agent_id: 'agent-2', + type: 'task_created', + message: 'task by editor', + agent_name: 'Editor', + created_at: new Date().toISOString(), + }, + ] + + const agents = [ + { id: 'agent-1', name: 'Writer' }, + { id: 'agent-2', name: 'Editor' }, + ] + + render() + + // Filter by agent-1 + await user.click(screen.getByTestId('agent-chip-agent-1')) + // Filter by tasks type + await user.click(screen.getByTestId('feed-tab-tasks')) + + // Only task_created by agent-1 should be visible + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() + expect(screen.queryByTestId('activity-3')).not.toBeInTheDocument() + }) }) describe('loading state', () => { @@ -207,20 +404,78 @@ describe('LiveFeed', () => { describe('activity types', () => { it('renders different icons for different activity types', () => { const activities: ActivityData[] = [ - { id: '1', squad_id: 's1', type: 'task_created', message: 'task', created_at: new Date().toISOString() }, - { id: '2', squad_id: 's1', type: 'task_assigned', message: 'assigned', created_at: new Date().toISOString() }, - { id: '3', squad_id: 's1', type: 'message_sent', message: 'message', created_at: new Date().toISOString() }, - { id: '4', squad_id: 's1', type: 'document_created', message: 'document', created_at: new Date().toISOString() }, + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'task_created', message: 'task', created_at: new Date().toISOString() }, + { id: '2', squad_id: 's1', agent_id: 'a1', type: 'task_assigned', message: 'assigned', created_at: new Date().toISOString() }, + { id: '3', squad_id: 's1', agent_id: 'a1', type: 'message_sent', message: 'message', created_at: new Date().toISOString() }, + { id: '4', squad_id: 's1', agent_id: 'a1', type: 'document_created', message: 'document', created_at: new Date().toISOString() }, { id: '5', squad_id: 's1', type: 'agent_status_changed', message: 'status', created_at: new Date().toISOString() }, ] render() - // All activities should render + // Non-system activities should render as FeedEntry expect(screen.getByTestId('activity-1')).toBeInTheDocument() expect(screen.getByTestId('activity-2')).toBeInTheDocument() expect(screen.getByTestId('activity-3')).toBeInTheDocument() expect(screen.getByTestId('activity-4')).toBeInTheDocument() - expect(screen.getByTestId('activity-5')).toBeInTheDocument() + // System activity (agent_status_changed) renders as SystemMessage + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('status')).toBeInTheDocument() + }) + }) + + describe('system message rendering', () => { + it('renders SystemMessage for activities without agent_id', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'task_created', message: 'system task created', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('system task created')).toBeInTheDocument() + }) + + it('renders SystemMessage for agent_status_changed activities', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'agent_status_changed', message: 'Agent activated', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('Agent activated')).toBeInTheDocument() + }) + + it('renders warning variant for messages containing paused or offline', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'agent_status_changed', message: 'Agent PAUSED by user', created_at: new Date().toISOString() }, + ] + const { container } = render() + + const systemMsg = screen.getByTestId('system-message') + expect(systemMsg).toBeInTheDocument() + // Warning variant uses border-status-blocked styling + expect(systemMsg.className).toContain('border-status-blocked') + }) + + it('renders info variant for non-warning system messages', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'agent_status_changed', message: 'Agent activated', created_at: new Date().toISOString() }, + ] + render() + + const systemMsg = screen.getByTestId('system-message') + expect(systemMsg).toBeInTheDocument() + // Info variant uses border-accent styling + expect(systemMsg.className).toContain('border-accent') + }) + + it('renders FeedEntry for activities with agent_id and non-status type', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'task_created', message: 'created task', agent_name: 'Writer', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.queryByTestId('system-message')).not.toBeInTheDocument() }) }) @@ -253,6 +508,20 @@ describe('LiveFeed', () => { // Either the li or inner element should have article role expect(activity).toHaveAttribute('role', 'article') }) + + it('agent filter chips have radiogroup role', () => { + render() + + const chips = screen.getByTestId('agent-filter-chips') + expect(chips).toHaveAttribute('role', 'radiogroup') + }) + + it('feed type tabs have tablist role', () => { + render() + + const tabs = screen.getByTestId('feed-type-tabs') + expect(tabs).toHaveAttribute('role', 'tablist') + }) }) describe('styling', () => { diff --git a/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx b/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx index 16e37cd..664a70d 100644 --- a/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx +++ b/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx @@ -1,10 +1,14 @@ 'use client' -import { useMemo } from 'react' -import Link from 'next/link' +import { useMemo, useState } from 'react' import { cn } from '@/lib/utils' -import { Avatar, Icon, Skeleton, Text } from '@/components/atoms' +import { Icon, Skeleton, StatusDot, Text } from '@/components/atoms' +import { AgentFilterChips } from '@/components/molecules/AgentFilterChips' +import { FeedTypeTabs } from '@/components/molecules/FeedTypeTabs' +import type { FeedType } from '@/components/molecules/FeedTypeTabs' +import { FeedEntry } from '@/components/molecules/FeedEntry' +import { SystemMessage } from '@/components/molecules/SystemMessage' /** * Activity type enum matching database schema @@ -56,73 +60,32 @@ export interface LiveFeedProps { maxItems?: number /** Function to generate href for task links. Defaults to `/tasks/${taskId}` */ getTaskHref?: (taskId: string) => string + /** List of agents for the filter chips */ + agents?: Array<{ id: string; name: string }> /** Additional CSS classes */ className?: string } -// Type for icon names we use -type IconNameType = - | 'ListPlus' - | 'ArrowRightLeft' - | 'UserCheck' - | 'MessageSquare' - | 'FilePlus' - | 'Zap' - | 'Activity' - | 'Loader' - | 'ChevronDown' - | 'Users' - /** - * Maps activity type to an icon name + * Maps FeedType to the ActivityType values it includes */ -function getActivityIcon(type: ActivityType): IconNameType { - const iconMap: Record = { - task_created: 'ListPlus', - task_status_changed: 'ArrowRightLeft', - task_assigned: 'UserCheck', - message_sent: 'MessageSquare', - document_created: 'FilePlus', - agent_status_changed: 'Zap', - } - return iconMap[type] || 'Activity' +const feedTypeMap: Record, ActivityType[]> = { + tasks: ['task_created', 'task_status_changed', 'task_assigned'], + comments: ['message_sent'], + docs: ['document_created'], + status: ['agent_status_changed'], } /** - * Maps activity type to a color class for the icon background + * Determines which FeedType bucket an ActivityType belongs to */ -function getActivityColor(type: ActivityType): string { - const colorMap: Record = { - task_created: 'bg-green-100 text-green-600', - task_status_changed: 'bg-blue-100 text-blue-600', - task_assigned: 'bg-purple-100 text-purple-600', - message_sent: 'bg-accent-muted text-accent', - document_created: 'bg-orange-100 text-orange-600', - agent_status_changed: 'bg-yellow-100 text-yellow-600', +function getFeedTypeForActivity(type: ActivityType): Exclude { + for (const [feedType, activityTypes] of Object.entries(feedTypeMap)) { + if (activityTypes.includes(type)) { + return feedType as Exclude + } } - return colorMap[type] || 'bg-background-elevated text-text-muted' -} - -/** - * Formats a timestamp to relative time (e.g., "2m ago", "1h ago") - */ -function formatRelativeTime(timestamp: string | null | undefined): string { - if (!timestamp) return '' - - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.floor(diffMs / 1000) - const diffMin = Math.floor(diffSec / 60) - const diffHour = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHour / 24) - - if (diffSec < 60) return 'just now' - if (diffMin < 60) return `${diffMin}m ago` - if (diffHour < 24) return `${diffHour}h ago` - if (diffDay < 7) return `${diffDay}d ago` - - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return 'tasks' } /** @@ -130,6 +93,7 @@ function formatRelativeTime(timestamp: string | null | undefined): string { * * Displays a real-time activity stream showing recent events in the squad. * Activities are displayed newest first with icons indicating the type of activity. + * Supports filtering by agent and activity type. * * Uses `content-visibility: auto` for performance optimization on long lists. * @@ -138,6 +102,7 @@ function formatRelativeTime(timestamp: string | null | undefined): string { * fetchMore()} * /> * ``` @@ -149,15 +114,61 @@ export function LiveFeed({ onLoadMore, maxItems, getTaskHref, + agents, className, }: LiveFeedProps) { - // Limit activities if maxItems is set - const displayedActivities = useMemo(() => { - if (maxItems && activities.length > maxItems) { - return activities.slice(0, maxItems) + const [selectedAgentId, setSelectedAgentId] = useState(null) + const [selectedType, setSelectedType] = useState('all') + + function isSystemActivity(activity: ActivityData): boolean { + return !activity.agent_id || activity.type === 'agent_status_changed' + } + + // Compute counts per agent from full activities array + const agentCounts = useMemo(() => { + if (!agents) return undefined + const counts: Record = {} + for (const activity of activities) { + if (activity.agent_id) { + counts[activity.agent_id] = (counts[activity.agent_id] || 0) + 1 + } + } + return agents.map((agent) => ({ + ...agent, + activityCount: counts[agent.id] || 0, + })) + }, [agents, activities]) + + // Compute counts per feed type from full activities array + const typeCounts = useMemo(() => { + const counts: Partial> = { all: activities.length } + for (const activity of activities) { + const feedType = getFeedTypeForActivity(activity.type) + counts[feedType] = (counts[feedType] || 0) + 1 + } + return counts + }, [activities]) + + // Filter and limit activities + const filteredActivities = useMemo(() => { + let filtered = activities + + if (selectedAgentId) { + filtered = filtered.filter((a) => a.agent_id === selectedAgentId) + } + + if (selectedType !== 'all') { + filtered = filtered.filter((a) => + feedTypeMap[selectedType]?.includes(a.type) + ) } - return activities - }, [activities, maxItems]) + + if (maxItems) { + filtered = filtered.slice(0, maxItems) + } + + return filtered + }, [activities, selectedAgentId, selectedType, maxItems]) if (isLoading && activities.length === 0) { return ( @@ -193,21 +204,61 @@ export function LiveFeed({ > + {/* Agent filter chips */} + {agentCounts && agentCounts.length > 0 && ( + + )} + + {/* Feed type tabs */} + + {/* Activity list with content-visibility for performance */}
      - {displayedActivities.map((activity, index) => ( - - ))} + {filteredActivities.map((activity, index) => { + const isSystem = isSystemActivity(activity) + const borderClass = index < filteredActivities.length - 1 ? 'border-b border-border' : undefined + if (isSystem) { + return ( +
    • + +
    • + ) + } + return ( + + ) + })}
    + {/* Empty filtered state */} + {filteredActivities.length === 0 && ( +
    + + No activities match the current filters + +
    + )} + {/* Load more indicator */} {hasMore && ( - - - {/* Accessible title for Radix — always rendered */} + {/* Accessible title for Radix -- always rendered */} {agent ? `${agent.name} profile` : 'Loading agent profile'} + {/* Panel top bar */} +
    +
    + + + AGENT PROFILE + +
    + + + +
    + {/* Header */}
    {agent ? ( <> @@ -368,7 +422,12 @@ export function AgentProfilePanel({ {agent.name}
    - + {agent.role}
    + {/* Status detail box */} + {agent && (agent.statusReason || agent.statusSince) && ( + + )} + {/* Description */} {agent.description && ( + + Attention + {attentionCount != null && attentionCount > 0 && ( + + {attentionCount} + + )} + Timeline Messages + + {attentionContent ?? ( + + No items need attention + + )} + + )} + + {onSendMessage && ( +
    + + SEND MESSAGE TO {agent.name.toUpperCase()} + + +
    + )}
    ) : ( diff --git a/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx b/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx index e6dd30a..d4c89a8 100644 --- a/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx +++ b/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx @@ -18,6 +18,8 @@ export interface DashboardLayoutProps { header?: React.ReactNode /** Name of the currently focused agent (displays "IN FOCUS: [Agent]" in header) */ focusedAgent?: string | null + /** Override content for the right panel (replaces feed when set) */ + rightPanelOverride?: React.ReactNode /** Additional CSS classes to apply to the root element */ className?: string } @@ -59,6 +61,7 @@ export function DashboardLayout({ feed, header, focusedAgent, + rightPanelOverride, className, }: DashboardLayoutProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) @@ -192,7 +195,7 @@ export function DashboardLayout({ {/* Right feed column - hidden on mobile */} - {feed && ( + {(feed || rightPanelOverride) && ( )} diff --git a/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx b/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx new file mode 100644 index 0000000..d63d95d --- /dev/null +++ b/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx @@ -0,0 +1,467 @@ +'use client' + +import * as React from 'react' +import * as TabsPrimitive from '@radix-ui/react-tabs' + +import { cn } from '@/lib/utils' +import { Badge, Button, Icon, Skeleton, StatusDot, Text } from '@/components/atoms' +import type { TaskData, TaskStatus, TaskPriority } from '@/components/organisms' + +/** + * Maps task priority to badge variant + */ +function getPriorityBadgeVariant( + priority: TaskPriority +): 'priority-urgent' | 'priority-high' | 'priority-normal' | 'priority-low' { + const variants = { + urgent: 'priority-urgent', + high: 'priority-high', + normal: 'priority-normal', + low: 'priority-low', + } as const + return variants[priority] +} + +/** + * Maps task status to badge variant + */ +function getStatusBadgeVariant( + status: TaskStatus +): 'status-active' | 'status-idle' | 'status-blocked' | 'default' { + const variants: Record = { + inbox: 'default', + assigned: 'status-idle', + in_progress: 'status-active', + review: 'status-active', + done: 'status-idle', + blocked: 'status-blocked', + } + return variants[status] +} + +/** + * Human-readable status labels + */ +function getStatusLabel(status: TaskStatus): string { + const labels: Record = { + inbox: 'Inbox', + assigned: 'Assigned', + in_progress: 'In Progress', + review: 'Review', + done: 'Done', + blocked: 'Blocked', + } + return labels[status] +} + +/** + * Human-readable priority labels + */ +function getPriorityLabel(priority: TaskPriority): string { + const labels: Record = { + urgent: 'Urgent', + high: 'High', + normal: 'Normal', + low: 'Low', + } + return labels[priority] +} + +/** + * Loading skeleton for the inline panel content + */ +function InlinePanelSkeleton() { + return ( +
    + {/* Title skeleton */} + + + {/* Badges skeleton */} +
    + + +
    + + {/* Description skeleton */} +
    + + + +
    + + {/* Separator */} +
    + + {/* Content area skeleton */} +
    + + +
    +
    + ) +} + +/** + * Props for the TaskDetailInlinePanel component. + */ +export interface TaskDetailInlinePanelProps { + /** The task to display details for (null shows loading skeleton) */ + task: TaskData | null + /** Callback when the close button is clicked */ + onClose: () => void + /** Callback when the edit button is clicked */ + onEdit?: (taskId: string) => void + /** Callback when the delete button is clicked */ + onTaskDelete?: (taskId: string) => Promise | void + /** Children to render in the details tab body (for comments, documents, etc.) */ + children?: React.ReactNode + /** Content to render in the deliverables tab. When provided, replaces the default empty state. */ + deliverablesContent?: React.ReactNode + /** Additional CSS classes */ + className?: string +} + +/** + * TaskDetailInlinePanel is a non-modal inline panel that renders task details + * within a container (typically the right sidebar of the dashboard layout). + * + * Unlike TaskDetailPanel (which uses a Radix Dialog overlay), this component + * is a regular `
    + + {/* Scrollable body */} +
    {task ? ( - <> - + {/* Task title */} +

    {task.title} - +

    + {/* Badges */}
    - - ) : ( - <> - -
    - - -
    - - )} -
    - {/* Body - scrollable content area */} -
    - {task ? ( -
    {/* Description */} {task.description && ( )} - {/* Visual separator if description exists */} - {task.description && children && ( -
    + {/* Assignees */} + {task.assignees && task.assignees.length > 0 && ( +
    + + Assignees + +
    + {task.assignees.map((assignee) => ( + + {assignee.name} + + ))} +
    +
    )} - {/* Children slot for comments, documents, etc. */} - {children} + {/* Separator + children (deliverables content, etc.) */} + {children && ( + <> +
    + {children} + + )}
    ) : ( )}
    - - {/* Footer with action buttons */} -
    -
    - {onTaskDelete && task && ( - - )} -
    - -
    - {onTaskUpdate && task && ( - - )} - -
    -
    diff --git a/apps/web/src/components/templates/TaskDetailPanel/index.ts b/apps/web/src/components/templates/TaskDetailPanel/index.ts index 98668d8..cffd246 100644 --- a/apps/web/src/components/templates/TaskDetailPanel/index.ts +++ b/apps/web/src/components/templates/TaskDetailPanel/index.ts @@ -1,2 +1,4 @@ export { TaskDetailPanel } from './TaskDetailPanel' export type { TaskDetailPanelProps } from './TaskDetailPanel' +export { TaskDetailInlinePanel } from './TaskDetailInlinePanel' +export type { TaskDetailInlinePanelProps } from './TaskDetailInlinePanel' diff --git a/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx b/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx new file mode 100644 index 0000000..28d09ec --- /dev/null +++ b/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx @@ -0,0 +1,849 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' + +import { AgentProfilePanel } from '@/components/templates' +import type { AgentProfileData } from '@/components/templates' +import { Button } from '@/components/atoms' + +const mockAgent: AgentProfileData = { + id: 'agent-1', + name: 'Writer Agent', + role: 'Content Lead', + status: 'active', + avatarColor: 'blue', + currentTask: 'Writing blog post', + blockedReason: null, + description: 'A creative writing agent specializing in long-form content.', + personality: 'Detail-oriented and creative', + expertise: ['SEO', 'Copywriting', 'Research'], + collaborates_with: ['Editor Agent', 'Social Agent'], + statusReason: 'Working on blog draft', + statusSince: '2026-02-05T08:00:00Z', +} + +/** + * Helper wrapper that provides a trigger button for opening the panel. + */ +function TestWrapper({ + agent = mockAgent, + ...props +}: Partial> & { + agent?: AgentProfileData | null +}) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} + +describe('AgentProfilePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Panel header (top bar)', () => { + it('shows "AGENT PROFILE" label with StatusDot in top bar', () => { + render( + + ) + + const topBar = screen.getByTestId('agent-profile-top-bar') + expect(topBar).toBeInTheDocument() + expect(topBar).toHaveTextContent('AGENT PROFILE') + + // StatusDot should be present in the top bar + const statusDot = within(topBar).getByRole('status') + expect(statusDot).toBeInTheDocument() + }) + + it('has close button in top bar', () => { + render( + + ) + + const closeButton = screen.getByTestId('agent-profile-close-button') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('closes panel when close button is clicked', async () => { + const user = userEvent.setup() + + render() + + // Open + await user.click(screen.getByTestId('trigger-button')) + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + // Close + await user.click(screen.getByTestId('agent-profile-close-button')) + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + }) + + describe('Agent info display', () => { + it('displays agent name', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-name')).toHaveTextContent('Writer Agent') + }) + + it('displays agent role badge', () => { + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge).toHaveTextContent('Content Lead') + }) + + it('displays agent status badge', () => { + render( + + ) + + const workingElements = screen.getAllByText('Working') + expect(workingElements.length).toBeGreaterThanOrEqual(1) + }) + + it('displays agent description', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-description')).toHaveTextContent( + 'A creative writing agent specializing in long-form content.' + ) + }) + }) + + describe('ABOUT section', () => { + it('renders ABOUT heading with correct styling', () => { + render( + + ) + + const bioSection = screen.getByTestId('agent-bio-section') + expect(bioSection).toBeInTheDocument() + expect(bioSection).toHaveTextContent('ABOUT') + }) + + it('renders personality quote', () => { + render( + + ) + + expect(screen.getByTestId('agent-bio-personality')).toHaveTextContent( + 'Detail-oriented and creative' + ) + }) + + it('renders expertise tags', () => { + render( + + ) + + const expertiseList = screen.getByTestId('agent-bio-expertise-list') + expect(expertiseList).toBeInTheDocument() + expect(within(expertiseList).getByText('SEO')).toBeInTheDocument() + expect(within(expertiseList).getByText('Copywriting')).toBeInTheDocument() + expect(within(expertiseList).getByText('Research')).toBeInTheDocument() + }) + + it('renders collaborators list', () => { + render( + + ) + + const collabList = screen.getByTestId('agent-bio-collaborates-list') + expect(within(collabList).getByText('Editor Agent')).toBeInTheDocument() + expect(within(collabList).getByText('Social Agent')).toBeInTheDocument() + }) + + it('does not render bio section when all fields are empty', () => { + const agentNoProfile: AgentProfileData = { + ...mockAgent, + personality: null, + expertise: null, + collaborates_with: null, + } + + render( + + ) + + expect(screen.queryByTestId('agent-bio-section')).not.toBeInTheDocument() + }) + }) + + describe('Attention tab', () => { + it('renders Attention tab trigger', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-attention')).toBeInTheDocument() + expect(screen.getByTestId('agent-profile-tab-attention')).toHaveTextContent('Attention') + }) + + it('shows count badge when attentionCount > 0', () => { + render( + + ) + + const badge = screen.getByTestId('attention-count-badge') + expect(badge).toBeInTheDocument() + expect(badge).toHaveTextContent('5') + }) + + it('does not show count badge when attentionCount is 0', () => { + render( + + ) + + expect(screen.queryByTestId('attention-count-badge')).not.toBeInTheDocument() + }) + + it('does not show count badge when attentionCount is undefined', () => { + render( + + ) + + expect(screen.queryByTestId('attention-count-badge')).not.toBeInTheDocument() + }) + + it('defaults to attention tab when attentionCount > 0', () => { + render( + Items here
    } + /> + ) + + // Attention tab should be active by default + const attentionTab = screen.getByTestId('agent-profile-tab-attention') + expect(attentionTab).toHaveAttribute('data-state', 'active') + + // Attention content should be visible + expect(screen.getByTestId('custom-attention-content')).toBeInTheDocument() + }) + + it('defaults to timeline tab when attentionCount is 0', () => { + render( + + ) + + const timelineTab = screen.getByTestId('agent-profile-tab-timeline') + expect(timelineTab).toHaveAttribute('data-state', 'active') + }) + + it('renders attention content when provided', () => { + render( + Custom content
    } + /> + ) + + expect(screen.getByTestId('custom-attention')).toBeInTheDocument() + }) + + it('shows fallback when no attention content is provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Click the attention tab + await user.click(screen.getByTestId('agent-profile-tab-attention')) + + expect(screen.getByText('No items need attention')).toBeInTheDocument() + }) + }) + + describe('Timeline and Messages tabs', () => { + it('renders Timeline tab', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-timeline')).toBeInTheDocument() + }) + + it('renders Messages tab', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-messages')).toBeInTheDocument() + }) + + it('displays timeline content', async () => { + const user = userEvent.setup() + + render( + Timeline items} + /> + ) + + // Click the timeline tab + await user.click(screen.getByTestId('agent-profile-tab-timeline')) + expect(screen.getByTestId('custom-timeline')).toBeInTheDocument() + }) + + it('displays messages content', async () => { + const user = userEvent.setup() + + render( + Message items} + /> + ) + + // Click the messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + expect(screen.getByTestId('custom-messages')).toBeInTheDocument() + }) + }) + + describe('DirectMessageInput in Messages tab', () => { + it('renders DirectMessageInput when onSendMessage is provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Navigate to messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + + const dmSection = screen.getByTestId('agent-profile-dm-section') + expect(dmSection).toBeInTheDocument() + expect(dmSection).toHaveTextContent('SEND MESSAGE TO WRITER AGENT') + expect(screen.getByTestId('direct-message-input')).toBeInTheDocument() + }) + + it('does not render DirectMessageInput when onSendMessage is not provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Navigate to messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + + expect(screen.queryByTestId('agent-profile-dm-section')).not.toBeInTheDocument() + }) + }) + + describe('Role badge colors', () => { + it('applies Lead role badge styling', () => { + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-accent') + expect(roleBadge.className).toContain('text-white') + }) + + it('applies Integration role badge styling', () => { + const integrationAgent: AgentProfileData = { + ...mockAgent, + role: 'Integration Engineer', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-priority-normal') + expect(roleBadge.className).toContain('text-white') + }) + + it('applies Specialist role badge styling', () => { + const specialistAgent: AgentProfileData = { + ...mockAgent, + role: 'SEO Specialist', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-status-active') + expect(roleBadge.className).toContain('text-white') + }) + + it('uses default styling for unrecognized roles', () => { + const genericAgent: AgentProfileData = { + ...mockAgent, + role: 'General Helper', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + // Should not have any of the special role classes + expect(roleBadge.className).not.toContain('bg-accent') + expect(roleBadge.className).not.toContain('bg-priority-normal') + expect(roleBadge.className).not.toContain('bg-status-active') + }) + }) + + describe('Loading skeleton', () => { + it('shows skeleton when agent is null', () => { + render( + + ) + + // Panel should still render with loading state + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + expect(screen.getByText('Loading agent profile')).toBeInTheDocument() + }) + + it('does not render tabs when loading', () => { + render( + + ) + + expect(screen.queryByTestId('agent-profile-tabs')).not.toBeInTheDocument() + }) + + it('still shows top bar when loading', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-top-bar')).toBeInTheDocument() + expect(screen.getByTestId('agent-profile-top-bar')).toHaveTextContent('AGENT PROFILE') + }) + }) + + describe('Panel open/close', () => { + it('opens panel on trigger click', async () => { + const user = userEvent.setup() + + render() + + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + }) + + it('closes panel on overlay click', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('agent-profile-overlay')) + + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + + it('closes panel on Escape key', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + await user.keyboard('{Escape}') + + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + + it('calls onOpenChange with false on close', async () => { + const onOpenChange = vi.fn() + const user = userEvent.setup() + + render( + + ) + + await user.keyboard('{Escape}') + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) + + describe('Accessibility', () => { + it('panel has aria-labelledby pointing to title', () => { + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).toHaveAttribute('aria-labelledby', 'agent-profile-title') + }) + + it('panel has aria-describedby when agent has description', () => { + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).toHaveAttribute('aria-describedby', 'agent-profile-description') + }) + + it('panel does not have aria-describedby when agent has no description', () => { + const agentNoDesc: AgentProfileData = { + ...mockAgent, + description: null, + } + + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).not.toHaveAttribute('aria-describedby') + }) + + it('close button has aria-label', () => { + render( + + ) + + const closeButton = screen.getByTestId('agent-profile-close-button') + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('DialogTitle is always rendered with sr-only', () => { + render( + + ) + + const title = screen.getByText('Writer Agent profile') + expect(title).toBeInTheDocument() + expect(title.className).toContain('sr-only') + }) + }) +}) + +describe('AgentStatusBox', () => { + // Import dynamically since it's a molecule + it('renders with status badge', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + const box = screen.getByTestId('agent-status-box') + expect(box).toBeInTheDocument() + expect(screen.getByText('Working')).toBeInTheDocument() + }) + + it('renders status reason when provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + expect(screen.getByText('Status Reason:')).toBeInTheDocument() + expect(screen.getByText('Waiting for code review')).toBeInTheDocument() + }) + + it('renders since timestamp when provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + // Use a recent date so formatRelativeTime returns something predictable + const now = new Date() + const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString() + + render( + + ) + + expect(screen.getByText(/Since/)).toBeInTheDocument() + }) + + it('does not render reason when not provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + expect(screen.queryByText('Status Reason:')).not.toBeInTheDocument() + }) + + it('applies correct border color for each status', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + const { rerender } = render( + + ) + + let box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-active') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-blocked') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-offline') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-idle') + }) +}) + +describe('AttentionList', () => { + it('renders items', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + const items = [ + { id: '1', type: 'mention' as const, title: '@Writer mentioned you' }, + { id: '2', type: 'waiting_task' as const, title: 'Review draft', description: 'Blog post needs review' }, + ] + + render() + + const list = screen.getByTestId('attention-list') + expect(list).toBeInTheDocument() + + const listItems = screen.getAllByTestId('attention-list-item') + expect(listItems).toHaveLength(2) + + expect(screen.getByText('@Writer mentioned you')).toBeInTheDocument() + expect(screen.getByText('Review draft')).toBeInTheDocument() + expect(screen.getByText('Blog post needs review')).toBeInTheDocument() + }) + + it('renders empty state when no items', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + render() + + expect(screen.getByTestId('attention-list-empty')).toBeInTheDocument() + expect(screen.getByText('No items need attention')).toBeInTheDocument() + }) + + it('renders timestamp when provided', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + const now = new Date() + const twoMinAgo = new Date(now.getTime() - 2 * 60 * 1000).toISOString() + + const items = [ + { id: '1', type: 'mention' as const, title: 'Test mention', timestamp: twoMinAgo }, + ] + + render() + + // formatRelativeTime should produce "2m ago" + expect(screen.getByText('2m ago')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx b/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx new file mode 100644 index 0000000..b903e63 --- /dev/null +++ b/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx @@ -0,0 +1,416 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TaskDetailInlinePanel } from '@/components/templates' +import type { TaskData } from '@/components/organisms' + +describe('TaskDetailInlinePanel', () => { + const mockTask: TaskData = { + id: 'task-1', + title: 'Test Task Title', + description: 'This is a test task description.', + status: 'in_progress', + priority: 'high', + position: 0, + assignees: [ + { id: 'agent-1', name: 'Writer Agent' }, + ], + created_at: '2026-02-01T10:00:00Z', + updated_at: '2026-02-05T14:00:00Z', + tags: ['content', 'sprint-1'], + } + + const mockOnClose = vi.fn() + const mockOnEdit = vi.fn() + const mockOnDelete = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('header rendering', () => { + it('renders "TASK DETAIL" header with StatusDot', () => { + render( + + ) + + expect(screen.getByText('TASK DETAIL')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('renders close button with aria-label', () => { + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-close')) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('task info rendering', () => { + it('shows task title', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-title')).toHaveTextContent('Test Task Title') + }) + + it('shows status badge', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-status')).toHaveTextContent('In Progress') + }) + + it('shows priority badge', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-priority')).toHaveTextContent('High') + }) + }) + + describe('tabs', () => { + it('renders Details and Deliverables tabs', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-tab-details')).toBeInTheDocument() + expect(screen.getByTestId('task-detail-inline-tab-deliverables')).toBeInTheDocument() + }) + + it('shows Details tab content by default', () => { + render( + + ) + + const detailsContent = screen.getByTestId('task-detail-inline-details-content') + expect(detailsContent).toBeInTheDocument() + }) + + it('shows task description in Details tab', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-description')).toHaveTextContent( + 'This is a test task description.' + ) + }) + + it('switches to Deliverables tab on click', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-tab-deliverables')) + + const deliverableContent = screen.getByTestId('task-detail-inline-deliverables-content') + expect(deliverableContent).toBeInTheDocument() + expect(deliverableContent).toHaveTextContent('No deliverables yet') + }) + }) + + describe('footer actions', () => { + it('renders edit button when onEdit is provided', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-edit')).toBeInTheDocument() + }) + + it('calls onEdit with task id when edit button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-edit')) + expect(mockOnEdit).toHaveBeenCalledWith('task-1') + }) + + it('renders delete button when onTaskDelete is provided', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-delete')).toBeInTheDocument() + }) + + it('calls onTaskDelete when delete button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-delete')) + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith('task-1') + }) + }) + + it('does not render edit button when onEdit is not provided', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-edit')).not.toBeInTheDocument() + }) + + it('does not render delete button when onTaskDelete is not provided', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-delete')).not.toBeInTheDocument() + }) + }) + + describe('loading state', () => { + it('shows loading skeleton when task is null', () => { + render( + + ) + + expect(screen.getByLabelText('Loading task details')).toBeInTheDocument() + }) + + it('does not show tabs when task is null', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-tabs')).not.toBeInTheDocument() + }) + }) + + describe('keyboard accessibility', () => { + it('close button responds to Enter key', async () => { + const user = userEvent.setup() + + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + closeButton.focus() + await user.keyboard('{Enter}') + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('close button responds to Space key', async () => { + const user = userEvent.setup() + + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + closeButton.focus() + await user.keyboard(' ') + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('edit button responds to Enter key', async () => { + const user = userEvent.setup() + + render( + + ) + + const editButton = screen.getByTestId('task-detail-inline-edit') + editButton.focus() + await user.keyboard('{Enter}') + + expect(mockOnEdit).toHaveBeenCalledWith('task-1') + }) + + it('delete button responds to Space key', async () => { + const user = userEvent.setup() + + render( + + ) + + const deleteButton = screen.getByTestId('task-detail-inline-delete') + deleteButton.focus() + await user.keyboard(' ') + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith('task-1') + }) + }) + }) + + describe('accessibility attributes', () => { + it('has role complementary on the aside element', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('role', 'complementary') + }) + + it('has descriptive aria-label with task title', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('aria-label', 'Task detail: Test Task Title') + }) + + it('has loading aria-label when task is null', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('aria-label', 'Task detail loading') + }) + }) + + describe('children slot', () => { + it('renders children in the details tab', () => { + render( + +
    Custom content
    +
    + ) + + expect(screen.getByTestId('custom-child')).toBeInTheDocument() + }) + }) + + describe('task with minimal data', () => { + it('renders without description', () => { + const minimalTask: TaskData = { + id: 'task-2', + title: 'Minimal Task', + status: 'inbox', + priority: 'low', + position: 1, + } + + render( + + ) + + expect(screen.getByTestId('task-detail-inline-title')).toHaveTextContent('Minimal Task') + expect(screen.queryByTestId('task-detail-inline-description')).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/components/templates/index.ts b/apps/web/src/components/templates/index.ts index 55e2e3f..877b50e 100644 --- a/apps/web/src/components/templates/index.ts +++ b/apps/web/src/components/templates/index.ts @@ -1,6 +1,6 @@ export * from './DashboardLayout' -export { TaskDetailPanel } from './TaskDetailPanel' -export type { TaskDetailPanelProps } from './TaskDetailPanel' +export { TaskDetailPanel, TaskDetailInlinePanel } from './TaskDetailPanel' +export type { TaskDetailPanelProps, TaskDetailInlinePanelProps } from './TaskDetailPanel' export { AgentProfilePanel } from './AgentProfilePanel' export type { AgentProfilePanelProps, AgentProfileData, AgentStatus } from './AgentProfilePanel' export { ActivityDetailPanel } from './ActivityDetailPanel' diff --git a/apps/web/src/hooks/useAgentAttention.ts b/apps/web/src/hooks/useAgentAttention.ts new file mode 100644 index 0000000..ef20e27 --- /dev/null +++ b/apps/web/src/hooks/useAgentAttention.ts @@ -0,0 +1,119 @@ +'use client' + +import { useState, useEffect } from 'react' +import { createClient } from '@/lib/supabase/browser' +import type { AttentionItem } from '@/components/molecules/AttentionList' + +export interface UseAgentAttentionResult { + /** Combined list of attention items (mentions + waiting tasks) */ + items: AttentionItem[] + /** Total count of attention items */ + totalCount: number + /** Whether the initial fetch is in progress */ + isLoading: boolean +} + +/** + * Hook for fetching items that require an agent's attention. + * + * Combines unread notifications and tasks with 'inbox' status assigned + * to the specified agent, sorted by timestamp (most recent first). + * + * @param agentId - The agent ID to fetch attention items for, or null to skip fetching + * @returns Object containing items, totalCount, and isLoading state + * + * @example + * ```tsx + * const { items, totalCount, isLoading } = useAgentAttention(agent.id) + * ``` + */ +export function useAgentAttention(agentId: string | null): UseAgentAttentionResult { + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!agentId) { + setItems([]) + return + } + + const fetchAttentionItems = async () => { + setIsLoading(true) + const supabase = createClient() + + try { + const attentionItems: AttentionItem[] = [] + + // Fetch unread notifications for this agent + const { data: notifications, error: notifError } = await supabase + .from('notifications') + .select('id, content, created_at') + .eq('mentioned_agent_id', agentId) + .eq('delivered', false) + .order('created_at', { ascending: false }) + .limit(20) + + if (!notifError && notifications) { + for (const notif of notifications) { + attentionItems.push({ + id: `notif-${notif.id}`, + type: 'mention', + title: notif.content ?? 'New mention', + timestamp: notif.created_at, + }) + } + } + + // Fetch tasks assigned to this agent that are in inbox/waiting status + const { data: taskAssignees, error: taskError } = await supabase + .from('task_assignees') + .select('task_id, tasks(id, title, description, created_at, status)') + .eq('agent_id', agentId) + .limit(20) + + if (!taskError && taskAssignees) { + for (const assignee of taskAssignees) { + const task = assignee.tasks as unknown as { + id: string + title: string + description: string | null + created_at: string + status: string + } | null + + if (task && task.status === 'inbox') { + attentionItems.push({ + id: `task-${task.id}`, + type: 'waiting_task', + title: task.title, + description: task.description ?? undefined, + timestamp: task.created_at, + }) + } + } + } + + // Sort by timestamp, most recent first + attentionItems.sort((a, b) => { + if (!a.timestamp || !b.timestamp) return 0 + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + }) + + setItems(attentionItems) + } catch { + // Error handling: keep existing items, log for debugging + console.error('Failed to fetch attention items') + } finally { + setIsLoading(false) + } + } + + fetchAttentionItems() + }, [agentId]) + + return { + items, + totalCount: items.length, + isLoading, + } +} diff --git a/apps/web/src/lib/formatRelativeTime.test.ts b/apps/web/src/lib/formatRelativeTime.test.ts new file mode 100644 index 0000000..70881fb --- /dev/null +++ b/apps/web/src/lib/formatRelativeTime.test.ts @@ -0,0 +1,54 @@ +import { formatRelativeTime } from './formatRelativeTime' + +describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-02-05T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns empty string for null input', () => { + expect(formatRelativeTime(null)).toBe('') + }) + + it('returns empty string for undefined input', () => { + expect(formatRelativeTime(undefined)).toBe('') + }) + + it('returns "just now" for times less than 60 seconds ago', () => { + const date = new Date('2026-02-05T11:59:30Z') + expect(formatRelativeTime(date)).toBe('just now') + }) + + it('returns minutes ago for times less than 60 minutes ago', () => { + const date = new Date('2026-02-05T11:55:00Z') + expect(formatRelativeTime(date)).toBe('5m ago') + }) + + it('returns hours ago for times less than 24 hours ago', () => { + const date = new Date('2026-02-05T09:00:00Z') + expect(formatRelativeTime(date)).toBe('3h ago') + }) + + it('returns days ago for times less than 7 days ago', () => { + const date = new Date('2026-02-03T12:00:00Z') + expect(formatRelativeTime(date)).toBe('2d ago') + }) + + it('returns formatted date for times 7 or more days ago', () => { + const date = new Date('2026-01-20T12:00:00Z') + expect(formatRelativeTime(date)).toBe('Jan 20') + }) + + it('accepts string input', () => { + expect(formatRelativeTime('2026-02-05T11:55:00Z')).toBe('5m ago') + }) + + it('accepts Date object input', () => { + const date = new Date('2026-02-05T11:55:00Z') + expect(formatRelativeTime(date)).toBe('5m ago') + }) +}) diff --git a/apps/web/src/lib/formatRelativeTime.ts b/apps/web/src/lib/formatRelativeTime.ts new file mode 100644 index 0000000..905973c --- /dev/null +++ b/apps/web/src/lib/formatRelativeTime.ts @@ -0,0 +1,36 @@ +/** + * Formats a date string or Date object as a relative time string. + * + * Returns human-readable relative timestamps like "just now", "5 min ago", + * "2 hours ago", "1 day ago", or a formatted date for older entries. + * + * @param dateInput - A date string, Date object, or null/undefined + * @returns A relative time string, or empty string if input is null/undefined + * + * @example + * ```ts + * formatRelativeTime(new Date()) // "just now" + * formatRelativeTime('2026-02-05T10:00:00Z') // "5 min ago" (if 5 min have passed) + * formatRelativeTime(null) // "" + * ``` + */ +export function formatRelativeTime( + dateInput: string | Date | null | undefined +): string { + if (!dateInput) return '' + + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHour = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHour / 24) + + if (diffSec < 60) return 'just now' + if (diffMin < 60) return `${diffMin}m ago` + if (diffHour < 24) return `${diffHour}h ago` + if (diffDay < 7) return `${diffDay}d ago` + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 67e9438..99dc105 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -11,9 +11,6 @@ const publicPaths = ['/api/setup'] */ const authPages = ['/login', '/signup'] -// Note: Authenticated users can also access /onboarding for squad creation. -// All other paths require authentication (handled below). - export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl @@ -74,7 +71,7 @@ export async function middleware(request: NextRequest) { // Redirect authenticated users away from auth pages to dashboard if (user && isAuthPage) { const url = request.nextUrl.clone() - url.pathname = '/dashboard' + url.pathname = '/tasks' return NextResponse.redirect(url) } diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index 9ba881d..d6f8c4d 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -115,7 +115,6 @@ services: NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000 NEXT_PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => {if(!r.ok) process.exit(1)}).catch(() => process.exit(1))\""] interval: 10s diff --git a/docs/plans/2026-02-05-feat-openclaw-integration-test-suite-plan.md b/docs/plans/2026-02-05-feat-openclaw-integration-test-suite-plan.md index 9ccdfff..bc2bd6a 100644 --- a/docs/plans/2026-02-05-feat-openclaw-integration-test-suite-plan.md +++ b/docs/plans/2026-02-05-feat-openclaw-integration-test-suite-plan.md @@ -162,10 +162,10 @@ The runtime SKILL.md (`skills/mission-control/SKILL.md`) has several discrepanci **1.5 Create OpenClaw agent configuration** - [x] Create `tests/integration/openclaw/openclaw.json` — Gateway config with: - - 3 agents defined: Lead, Writer, Social (no per-agent heartbeat prompts) - - Heartbeat interval: 2 minutes with Sonnet model - - Mission Control skill configured via `skills.entries` with apiKey and env - - `MISSION_CONTROL_AGENT_NAME` set per-agent via HEARTBEAT.md (not global config) + - 3 agents defined: Lead, Writer, Social + - Heartbeat interval: 10 seconds (test-optimized) + - Mission Control skill loaded from workspace + - Environment variables for API key and agent names - [x] Create `tests/integration/openclaw/agents/` — SOUL.md files (written dynamically by setup) - [x] Create `tests/integration/openclaw/skills/mission-control/SKILL.md` — Copy of the runtime skill @@ -267,11 +267,18 @@ Configure OpenClaw agents to connect to local Mission Control. **3.2 OpenClaw HEARTBEAT.md for Mission Control** -- [x] Create per-agent `tests/integration/openclaw/agents/*/HEARTBEAT.md` — Skill-based format: - - Each file sets `MISSION_CONTROL_AGENT_NAME` to the agent's name (Lead/Writer/Social) - - References the mission-control skill's Standing Orders - - Requires both API calls before HEARTBEAT_OK - - Template: `skills/mission-control/HEARTBEAT.md` (generic version without agent name) +- [x] Create `tests/integration/openclaw/HEARTBEAT.md` + ```markdown + # Mission Control Heartbeat + + On each heartbeat: + 1. Call POST /api/heartbeat with current status + 2. Process any notifications received + 3. Check for assigned tasks via GET /api/tasks?assigned_to={name} + 4. If tasks assigned and status is "inbox": pick up the first one + 5. If task in_progress: continue working, post progress comment + 6. If task done: check for next task + ``` **3.3 Agent SOUL.md templates (written dynamically)** @@ -281,11 +288,10 @@ Configure OpenClaw agents to connect to local Mission Control. **3.4 Mission Control skill for OpenClaw** -The existing `skills/mission-control/SKILL.md` runtime skill teaches agents the API. It's been rewritten to use proper OpenClaw frontmatter and is installed via `skills.entries` in the gateway config. +The existing `skills/mission-control/SKILL.md` runtime skill teaches agents the API. For integration tests, it's installed in the OpenClaw gateway's skill directory. -- [x] Verify the updated SKILL.md works with OpenClaw's skill loader -- [x] Test that agents can invoke the heartbeat and task APIs via the skill -- [x] Verified: 3 consecutive successful heartbeats on stock OpenClaw (no source patches) +- [ ] Verify the updated SKILL.md (from Phase 1.2) works with OpenClaw's skill loader +- [ ] Test that agents can invoke the heartbeat and task APIs via the skill #### Phase 4: Test Scenarios @@ -419,21 +425,6 @@ Individual test files for specific scenarios. | RLS policies break without full Supabase | Data isolation untested | Medium | Use GoTrue for auth; test RLS separately if needed | | Heartbeat auto-delivers notifications (Gap 13) | Can't test retry flow | Low | Document as known limitation; fix in Phase 04 integration work | -## Architecture Notes: Skill-Based Heartbeat - -The integration test uses the **skill-based heartbeat approach** verified on 2026-02-05: - -### How It Works -1. **SKILL.md** (`skills/mission-control/SKILL.md`) — OpenClaw skill with proper frontmatter. Description mentions "heartbeat check-in" so the model activates it during heartbeat polls. -2. **HEARTBEAT.md** (per-agent workspace file) — Standing orders that set `MISSION_CONTROL_AGENT_NAME` and reference the skill. Critical for first heartbeat (skill alone causes model to read but not execute). -3. **Model activation chain**: heartbeat prompt → scan `` → read SKILL.md → read HEARTBEAT.md → exec curl commands - -### Key Insights -- **Skill alone is insufficient**: On first heartbeat, model reads SKILL.md but still replies HEARTBEAT_OK. The HEARTBEAT.md creates the direct instruction chain. -- **No source patches needed**: Works with stock OpenClaw. Source patches at `~/dev/openclaw/` are preserved for potential upstream PR only. -- **Per-agent name via HEARTBEAT.md**: `skills.entries` env is global (same for all agents), so `MISSION_CONTROL_AGENT_NAME` is set per-agent in each workspace's HEARTBEAT.md. -- **Sonnet required for heartbeat model**: Haiku tends to skip skill instructions. Sonnet follows them reliably. - ## Future Considerations 1. **Notification retry mechanism** — Currently heartbeat auto-delivers. Phase 04 plans a two-step delivery model. Integration tests should be updated when that ships. diff --git a/docs/solutions/integration-issues/docker-compose-openclaw-supabase-setup-20260205.md b/docs/solutions/integration-issues/docker-compose-openclaw-supabase-setup-20260205.md index dcb83f9..1a8848a 100644 --- a/docs/solutions/integration-issues/docker-compose-openclaw-supabase-setup-20260205.md +++ b/docs/solutions/integration-issues/docker-compose-openclaw-supabase-setup-20260205.md @@ -647,567 +647,6 @@ Minimal working config for Docker: --- -## Verify Each Service (Sequential) - -Run these in order to pinpoint which service is broken: - -```bash -# 1. PostgreSQL -echo "1. PostgreSQL..." -docker exec mission-control-supabase-db-1 pg_isready -U postgres && echo "OK" || echo "FAIL" - -# 2. GoTrue -echo "2. GoTrue..." -curl -sf http://localhost:9998/health > /dev/null && echo "OK" || echo "FAIL" - -# 3. PostgREST -echo "3. PostgREST..." -bash -c "echo > /dev/tcp/localhost/3001" 2>/dev/null && echo "OK" || echo "FAIL" - -# 4. Kong -echo "4. Kong..." -curl -sf http://localhost:8000/auth/v1/health > /dev/null && echo "OK" || echo "FAIL" - -# 5. Mission Control -echo "5. Mission Control..." -curl -sf http://localhost:3100/api/health > /dev/null && echo "OK" || echo "FAIL" - -# 6. OpenClaw -echo "6. OpenClaw..." -curl -sf -H "Authorization: Bearer test-integration-token" http://localhost:18789/ > /dev/null && echo "OK" || echo "FAIL" -``` - ---- - -## Debug Commands - -```bash -# See what's listening on a port -lsof -i :3000 - -# Watch service startup in real-time -docker-compose -f docker-compose.integration.yml logs -f mission-control - -# Run a one-off command in a container -docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c "..." - -# Copy a file out of container -docker cp mission-control-supabase-db-1:/var/log/postgres.log /tmp/postgres.log - -# Check container resource usage -docker stats mission-control-supabase-db-1 - -# Inspect network -docker network inspect mission-control_integration -``` - ---- - -## Config Validation One-Liners - -```bash -# Validate OpenClaw JSON -node -e "JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))" - -# Validate docker-compose YAML -docker-compose -f docker-compose.integration.yml config > /dev/null - -# Validate mission-control Dockerfile -docker build --dry-run apps/web/Dockerfile . - -# Check all JSON files in openclaw directory -find tests/integration/openclaw -name "*.json" -exec sh -c 'node -e "JSON.parse(require(\"fs\").readFileSync(\"$1\"))" _ {}' \; -``` - ---- - -## Before Committing Changes - -1. Validate configs: - ```bash - docker-compose -f docker-compose.integration.yml config > /dev/null && \ - node -e "JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))" && \ - echo "All configs valid" - ``` - -2. Test on clean environment: - ```bash - docker-compose -f docker-compose.integration.yml down -v - docker-compose -f docker-compose.integration.yml up -d - sleep 60 # Let services start - curl http://localhost:3100/api/health # Should return 200 - ``` - -3. Check Git diff for unintended changes: - ```bash - git diff docker-compose.integration.yml # Should be minimal - git diff tests/integration/openclaw/config/ - ``` - ---- - -## Integration Test Execution - -```bash -# Run integration tests -docker-compose -f docker-compose.integration.yml up -d -sleep 60 # Wait for services to be healthy - -# In another terminal: -pnpm test:integration - -# Or specific test: -pnpm test tests/integration/api/heartbeat.test.ts - -# Stop services -docker-compose -f docker-compose.integration.yml down -``` - ---- - -## When Nothing Works - -Nuclear option (complete reset): - -```bash -# Stop everything -docker-compose -f docker-compose.integration.yml down -v -docker volume prune -f -docker network prune -f - -# Remove image to force rebuild -docker rmi openclaw:local 2>/dev/null || true - -# Rebuild and start fresh -docker-compose -f docker-compose.integration.yml build --no-cache -docker-compose -f docker-compose.integration.yml up -d - -# Wait and verify -sleep 120 -docker-compose -f docker-compose.integration.yml ps -curl http://localhost:3100/api/health -``` - ---- - -## Automated Validation Scripts - -Per-category bash scripts for validating specific aspects of the integration stack. Run individually to diagnose specific issues, or use the combined preflight script below. - -### pgcrypto Access - -```bash -#!/bin/bash -docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " - SET ROLE authenticator; - SELECT digest('test', 'sha256')::bytea; - RESET ROLE; -" 2>&1 | grep -i "error" && echo "FAIL: pgcrypto not accessible" || echo "PASS: pgcrypto accessible" -``` - -### Supabase Schemas Present - -```bash -#!/bin/bash -docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c " - SELECT schema_name FROM information_schema.schemata - WHERE schema_name IN ('public', 'storage', 'graphql_public', 'extensions') - ORDER BY schema_name; -" | grep -E "public|storage|extensions" || echo "FAIL: Supabase schemas missing" -``` - -### Service Role Passwords - -```bash -#!/bin/bash -for role in supabase_auth_admin authenticator supabase_storage_admin; do - echo "Testing $role..." - docker exec mission-control-supabase-db-1 psql -U "$role" -d postgres -c "SELECT 1;" 2>&1 | \ - grep -q "password authentication failed\|1" && echo "OK" || echo "FAIL" -done -``` - -### Runtime Environment Variables - -```bash -#!/bin/bash -curl -s http://localhost:3100/api/config | jq '.supabaseUrl' | \ - grep -q "supabase-kong" && echo "PASS: Runtime env vars active" || echo "FAIL: Build-time defaults in use" -``` - -### Health Check Tool Availability - -```bash -#!/bin/bash -for service in postgrest/postgrest:v12.0.2 kong:2.8.1 supabase/gotrue:v2.143.0; do - echo "Testing health check for $service..." - docker run --rm --entrypoint bash "$service" -c " - if command -v curl &> /dev/null; then echo 'curl available'; fi - if bash -c 'echo > /dev/tcp/localhost/3000' 2>/dev/null; then echo 'tcp available'; fi - " -done -``` - -### Kong Routing - -```bash -#!/bin/bash -echo "Testing Kong routing..." -curl -sf http://localhost:8000/auth/v1/health || echo "FAIL: Auth routing broken" -curl -sf http://localhost:8000/rest/v1/rpc/any_path -H "Authorization: Bearer test" > /dev/null && \ - echo "PASS: REST routing works" || echo "FAIL: REST routing broken" -``` - -### OpenClaw JSON Syntax - -```bash -#!/bin/bash -echo "Validating OpenClaw config files..." -for file in $(find . -path ./node_modules -prune -o -name "openclaw.json*" -print); do - if ! node -e "JSON.parse(require('fs').readFileSync('$file'))" 2>/dev/null; then - echo "FAIL: $file has invalid JSON syntax" - exit 1 - fi - echo "OK: $file valid" -done -echo "PASS: All OpenClaw configs valid" -``` - -### OpenClaw Gateway Mode - -```bash -#!/bin/bash -if grep -q '"mode".*:.*"local"' tests/integration/openclaw/config/openclaw.json; then - echo "PASS: gateway.mode is local" -else - echo "FAIL: gateway.mode not set to 'local'" - exit 1 -fi -``` - -### OpenClaw Auth Mode - -```bash -#!/bin/bash -if grep -q '"auth".*:.*"none"' tests/integration/openclaw/config/openclaw.json; then - echo "FAIL: auth mode cannot be 'none'" - exit 1 -fi -if ! grep -q '"auth".*:.*"\(token\|password\)"' tests/integration/openclaw/config/openclaw.json; then - echo "WARN: auth mode not explicitly set (will use default)" -fi -echo "PASS: auth mode is valid" -``` - -### OpenClaw LAN Binding - -```bash -#!/bin/bash -if grep -q "bind lan" tests/integration/scripts/entrypoint-openclaw.sh; then - if ! grep -q "bind lan.*--token\|--token.*bind lan" tests/integration/scripts/entrypoint-openclaw.sh; then - echo "FAIL: --bind lan requires --token flag" - exit 1 - fi -fi -echo "PASS: LAN binding has token configured" -``` - -### Port Conflicts - -```bash -#!/bin/bash -LOCAL_PORTS=(5432 3000 9999) -DOCKER_PORTS=(54332 3001 9998 8000 3100 18789) - -echo "Checking for port conflicts..." -has_conflict=0 -for port in "${LOCAL_PORTS[@]}"; do - if lsof -i ":$port" > /dev/null 2>&1; then - if ! docker ps --format "table {{.Ports}}" | grep -q ":$port"; then - echo "Warning: Local port $port in use (not Docker)" - fi - fi -done - -for port in "${DOCKER_PORTS[@]}"; do - if lsof -i ":$port" > /dev/null 2>&1; then - echo "FAIL: Docker port $port already in use" - has_conflict=1 - fi -done - -[ $has_conflict -eq 0 ] && echo "PASS: No Docker port conflicts" || exit 1 -``` - -### OpenClaw Model Config - -```bash -#!/bin/bash -node -e " - const cfg = JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json')); - const model = cfg.agents?.defaults?.model; - - if (typeof model === 'string') { - console.error('FAIL: model must be an object, not string'); - process.exit(1); - } - - if (typeof model !== 'object' || !model.primary) { - console.error('FAIL: model must be object with primary field'); - process.exit(1); - } - - console.log('PASS: model config is valid object'); -" || exit 1 -``` - ---- - -## Preflight Validation Script - -Combined script that runs all pre-launch checks. Save as `tests/integration/scripts/preflight-checks.sh`. - -```bash -#!/bin/bash -# tests/integration/scripts/preflight-checks.sh -set -euo pipefail - -echo "=== Mission Control + OpenClaw + Supabase Integration Preflight Checks ===" - -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' - -passed=0 -failed=0 - -check() { - local name="$1" - local cmd="$2" - - if eval "$cmd" > /dev/null 2>&1; then - echo -e "${GREEN}✓${NC} $name" - ((passed++)) - else - echo -e "${RED}✗${NC} $name" - ((failed++)) - fi -} - -# JSON validation -check "OpenClaw config is valid JSON" \ - "node -e \"JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json'))\"" - -check "OpenClaw model is object, not string" \ - "node -e \"const cfg = JSON.parse(require('fs').readFileSync('tests/integration/openclaw/config/openclaw.json')); if (typeof cfg.agents.defaults.model !== 'object') throw new Error('model must be object');\"" - -check "OpenClaw config has gateway.mode = 'local'" \ - "grep -q '\"mode\".*:.*\"local\"' tests/integration/openclaw/config/openclaw.json" - -check "OpenClaw config has valid auth mode" \ - "grep -q '\"auth\".*:.*\"\\(token\\|password\\)\"' tests/integration/openclaw/config/openclaw.json" - -# Docker configuration -check "docker-compose.integration.yml has valid YAML" \ - "docker-compose -f docker-compose.integration.yml config > /dev/null" - -check "No trailing commas in JSON files" \ - "! grep -r ',\\s*}' tests/integration/openclaw/ || ! grep -r ',\\s*\\]' tests/integration/openclaw/" - -check "PostgreSQL init script has search_path configuration" \ - "grep -q 'search_path' tests/integration/db/99-service-role-passwords.sql" - -check "Docker volumes mount individual files, not directories" \ - "! grep -q '/docker-entrypoint-initdb.d:' docker-compose.integration.yml || grep -q '/docker-entrypoint-initdb.d.*:ro' docker-compose.integration.yml" - -# Port configuration -check "No port conflicts (PostgreSQL)" \ - "! lsof -i :54332 > /dev/null || echo 'Port used by docker' && true" - -check "No port conflicts (GoTrue)" \ - "! lsof -i :9998 > /dev/null || echo 'Port used by docker' && true" - -check "Kong service is configured" \ - "grep -q 'supabase-kong:' docker-compose.integration.yml" - -check "Mission Control points to Kong gateway" \ - "grep -q 'NEXT_PUBLIC_SUPABASE_URL.*supabase-kong:8000' docker-compose.integration.yml" - -# Summary -echo "" -echo "=== Summary ===" -echo -e "Passed: ${GREEN}$passed${NC}" -echo -e "Failed: ${RED}$failed${NC}" - -if [ $failed -eq 0 ]; then - echo -e "${GREEN}All checks passed! Ready to run docker-compose up${NC}" - exit 0 -else - echo -e "${RED}Some checks failed. Review above and fix issues.${NC}" - exit 1 -fi -``` - ---- - -## Startup Verification Script - -Automated script that waits for each service to become healthy. Save as `tests/integration/scripts/verify-startup.sh`. - -```bash -#!/bin/bash -# tests/integration/scripts/verify-startup.sh -set -euo pipefail - -BASE_URL="${BASE_URL:-http://localhost:3100}" -GATEWAY_URL="${GATEWAY_URL:-http://localhost:18789}" -TIMEOUT=${TIMEOUT:-300} # 5 minutes - -echo "=== Verifying Integration Test Stack Startup ===" -echo "Base URL: $BASE_URL" -echo "Gateway URL: $GATEWAY_URL" -echo "Timeout: ${TIMEOUT}s" -echo "" - -wait_for() { - local url="$1" - local timeout="$2" - local elapsed=0 - - while [ $elapsed -lt $timeout ]; do - if curl -sf "$url" > /dev/null 2>&1; then - return 0 - fi - sleep 2 - elapsed=$((elapsed + 2)) - done - - return 1 -} - -echo "Checking PostgreSQL..." -wait_for "http://localhost:9998/health" $TIMEOUT || { echo "FAIL: PostgreSQL not responding"; exit 1; } -echo "OK: PostgreSQL ready" - -echo "Checking GoTrue (auth service)..." -wait_for "http://localhost:9998/health" $TIMEOUT || { echo "FAIL: GoTrue not responding"; exit 1; } -echo "OK: GoTrue ready" - -echo "Checking PostgREST (data API)..." -wait_for "http://localhost:3001" $TIMEOUT || { echo "FAIL: PostgREST not responding"; exit 1; } -echo "OK: PostgREST ready" - -echo "Checking Kong (API gateway)..." -wait_for "http://localhost:8000/auth/v1/health" $TIMEOUT || { echo "FAIL: Kong auth routing not working"; exit 1; } -echo "OK: Kong ready" - -echo "Checking Mission Control (app)..." -wait_for "$BASE_URL/api/health" $TIMEOUT || { - echo "FAIL: Mission Control not responding" - echo "Check logs: docker-compose -f docker-compose.integration.yml logs mission-control" - exit 1 -} -echo "OK: Mission Control ready" - -echo "Checking OpenClaw Gateway..." -wait_for "http://localhost:18789/ -H 'Authorization: Bearer test-integration-token'" $TIMEOUT || { - echo "FAIL: OpenClaw Gateway not responding" - echo "Check logs: docker-compose -f docker-compose.integration.yml logs openclaw-gateway" - exit 1 -} -echo "OK: OpenClaw Gateway ready" - -echo "" -echo "=== All services healthy! Integration tests can begin. ===" -``` - ---- - -## Troubleshooting Flowchart - -Detailed decision tree for diagnosing service failures. More granular than the Quick Decision Tree above. - -``` -Service X failing to start? -├─ Check logs: docker-compose logs -├─ Look for specific keywords: -│ ├─ "connection refused" → upstream dependency not ready -│ │ └─ Check depends_on, wait for upstream service_healthy -│ ├─ "password authentication failed" → wrong credentials -│ │ └─ Verify password set in 99-service-role-passwords.sql -│ ├─ "function X does not exist" → missing extension or search_path -│ │ └─ Verify search_path includes extensions schema -│ ├─ "port already in use" → port conflict -│ │ └─ Check lsof -i :PORT, change port in docker-compose.yml -│ ├─ "failed to parse config" → invalid JSON/YAML -│ │ └─ Validate: node -e "JSON.parse(...)" or docker-compose config -│ └─ "error from driver" or generic error → check service-specific issues below -│ -├─ If PostgreSQL not responding: -│ ├─ Check volume mounts (should mount individual files) -│ ├─ Verify 99-service-role-passwords.sql is being executed -│ └─ docker exec mission-control-supabase-db-1 psql -U postgres -d postgres -c "SELECT 1;" -│ -├─ If GoTrue/Auth failing: -│ ├─ Verify GOTRUE_DB_DATABASE_URL has correct role & password -│ ├─ Check PostgreSQL is healthy: docker-compose logs supabase-db | grep "ready" -│ ├─ Verify authenticator password is set: docker exec ... psql -U authenticator ... -│ └─ Check logs for auth-specific errors -│ -├─ If PostgREST failing: -│ ├─ Verify PGRST_DB_URI has correct role & password -│ ├─ Ensure TCP health check works: bash -c "echo > /dev/tcp/localhost/3000" -│ ├─ Not using curl (not in image) → must use bash tcp or other available tool -│ └─ Verify search_path is set for authenticator role -│ -├─ If Kong failing: -│ ├─ Verify kong.yml is present and valid YAML -│ ├─ Check upstream services (GoTrue, PostgREST) are healthy -│ ├─ Verify Kong volumes mount individual files: -│ │ - ./tests/integration/kong/kong.yml:/var/lib/kong/kong.yml:ro -│ └─ Test routing: curl http://localhost:8000/auth/v1/health -│ -├─ If Mission Control failing: -│ ├─ Check NEXT_PUBLIC_SUPABASE_URL points to Kong, not PostgREST directly -│ ├─ Verify Kong is healthy first -│ ├─ Check build logs for TypeScript/Next.js errors -│ ├─ For NEXT_PUBLIC vars, verify they're set in docker-compose environment -│ └─ Test endpoint: curl http://localhost:3100/api/health -│ -└─ If OpenClaw Gateway failing: - ├─ Verify openclaw.json is valid JSON (not JSON5) - ├─ Check gateway.mode = "local" - ├─ Verify model is object: { "primary": "..." }, not string - ├─ Check auth mode is "token" or "password", not "none" - ├─ If using --bind lan, ensure --token flag is present - ├─ Verify Mission Control API is reachable from gateway container - ├─ Test token auth: curl -H "Authorization: Bearer test-token" http://localhost:18789/ - └─ Check bootstrap process: docker-compose logs openclaw-gateway | grep "API key" -``` - ---- - -## Gotcha Summary Reference Card - -Quick-reference table mapping each issue to its core gotcha and prevention. - -| Issue | Gotcha | Prevention | -|-------|--------|-----------| -| pgcrypto digest() | Roles can't find extension functions | Set search_path on all roles to include extensions schema | -| Volume mounts | Mounting dir replaces all init scripts | Mount individual files, not directories | -| Service passwords | Env var PASSWORD doesn't apply to roles | Use ALTER USER to explicitly set role passwords | -| NEXT_PUBLIC env vars | Baked at build time, can't override | Use placeholders at build + runtime override or config endpoint | -| PostgREST health check | curl not available in image | Use bash TCP test: `echo > /dev/tcp/localhost/3000` | -| Supabase JS client | Needs Kong gateway, not PostgREST direct | Include Kong service, configure routes, use Kong URL | -| OpenClaw config | .json5 with comments/trailing commas fails | Use strict .json, no comments, no trailing commas | -| OpenClaw gateway.mode | Invalid mode silently fails | Explicitly set "local", never "cloud" or "none" | -| OpenClaw auth | No valid "none" mode, requires auth | Use "token" with --token flag, never try to disable | -| OpenClaw --bind lan | Requires token when network-accessible | Always pair `--bind lan` with `--token` flag | -| Port conflicts | Docker ports bind to localhost | Use non-standard ports (54332, 9998, etc) | -| Model config | String instead of object fails | Always use `{ "primary": "model-name" }`, never string | - ---- - ## Related Documentation - [Architecture](../../ARCHITECTURE.md) - System design overview diff --git a/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md b/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md new file mode 100644 index 0000000..9619b0b --- /dev/null +++ b/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md @@ -0,0 +1,169 @@ +--- +title: Supabase storageKey mismatch causes silent empty RLS results +date: 2026-02-05 +category: runtime-errors +tags: [supabase, auth, rls, next-js, client-side, storageKey] +module: dashboard +symptoms: + - "Client-side Supabase queries return empty results" + - "Deliverables not showing in task detail panel" + - "RLS returns empty array instead of error" + - "Server-side auth works but client-side queries fail silently" +root_cause: "Mismatched Supabase storageKey between browser clients" +severity: high +time_to_resolve: "45 minutes" +--- + +# Supabase storageKey Mismatch Causes Silent Empty RLS Results + +## Symptom + +Client-side Supabase queries (e.g., fetching deliverables from the `documents` table) return empty arrays despite data existing in the database. The behavior is deceptive because: + +- The user appears fully authenticated (dashboard loads, server components render correctly) +- HTTP responses return `200 OK`, not `401` or `403` +- RLS policies are correctly configured +- No errors appear in browser console or network tab +- Only client-side `'use client'` component queries are affected + +## Investigation + +1. **RLS policies on `documents` table** -- confirmed correct; authenticated users with matching `squad_id` should see results. + +2. **Network requests** -- all returned HTTP 200 with `data: []`. Supabase does not return auth errors for RLS-filtered queries; it returns empty results silently. + +3. **Browser JS console test** -- manually calling `supabase.auth.getSession()` from a client created via `@/lib/supabase/client` returned no session. Calling the same method on a client created via `@/lib/supabase/browser` returned a valid session. + +4. **localStorage inspection** -- no auth token stored under Supabase's default key. The token was stored under the custom key `sb-mission-control-auth-token`. + +5. **Import chain trace** -- `tasks-client.tsx` imported `createClient` from `@/lib/supabase/client` (no custom `storageKey`), not from `@/lib/supabase/browser` (which has the matching `storageKey`). + +## Root Cause + +The application had **two** Supabase browser client factories with different auth configurations: + +**`@/lib/supabase/client.ts`** -- NO custom storageKey (uses Supabase default): + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} +``` + +**`@/lib/supabase/browser.ts`** -- custom storageKey matching middleware: + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + storageKey: 'sb-mission-control-auth-token', + }, + } + ) +} +``` + +**`middleware.ts`** -- uses the SAME custom storageKey when creating the server client: + +```typescript +const supabase = createServerClient( + (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL)!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + storageKey: 'sb-mission-control-auth-token', + }, + cookies: { /* ... */ }, + } +) +``` + +The middleware stores the auth session under the custom key `sb-mission-control-auth-token`. Any browser client that does not specify this same `storageKey` cannot find the session cookie. It operates as unauthenticated, and RLS returns empty results instead of errors. + +### Why It Was Confusing + +| Observation | Why It Misled | +|---|---| +| Dashboard loaded correctly | Server components and middleware used the correct client | +| HTTP 200 responses | Supabase RLS returns empty arrays for unauthorized queries, not HTTP errors | +| User appeared logged in | Server-side auth (middleware) worked; only client-side was broken | +| No console errors | The Supabase client does not log warnings when it cannot find a session | + +## Solution + +Changed imports in three files from the incorrect client factory to the correct one: + +**Files modified:** + +- `apps/web/src/app/(dashboard)/tasks/tasks-client.tsx` +- `apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx` +- `apps/web/src/hooks/useAgentAttention.ts` + +**Change in each file:** + +```diff +- import { createClient } from '@/lib/supabase/client' ++ import { createClient } from '@/lib/supabase/browser' +``` + +After the fix, all three files use the browser client with the matching `storageKey`, and client-side queries return the expected data. + +## Prevention + +### 1. Single canonical browser client + +There should be ONE browser client factory, not two. Either remove `@/lib/supabase/client.ts` entirely or re-export from `browser.ts`: + +```typescript +// @/lib/supabase/client.ts -- deprecated, re-exports browser client +export { createClient } from './browser' +``` + +### 2. Lint rule for import path + +Add an ESLint rule or code review check to flag imports of `@/lib/supabase/client`: + +```jsonc +// .eslintrc or eslint.config.js +{ + "rules": { + "no-restricted-imports": ["error", { + "paths": [{ + "name": "@/lib/supabase/client", + "message": "Use @/lib/supabase/browser instead. The client.ts factory is missing the custom storageKey required for auth." + }] + }] + } +} +``` + +### 3. Document custom storageKey prominently + +When using a custom `storageKey` in Supabase SSR auth, add a comment in the middleware and in every client factory explaining the requirement: + +```typescript +// IMPORTANT: This storageKey MUST match the value in middleware.ts +// and all other Supabase client factories. Mismatched keys cause +// silent auth failures where RLS returns empty results. +storageKey: 'sb-mission-control-auth-token', +``` + +### 4. Test client-side data fetching with RLS + +Server-side auth success does not guarantee client-side auth works. Integration tests should verify that `'use client'` components can fetch RLS-protected data, not just that server components can. + +## Key Insight + +Supabase RLS is designed to fail silently -- unauthorized queries return empty results (`data: []`), not HTTP 401/403 errors. This is a security feature (it prevents leaking table structure), but it makes auth misconfigurations extremely difficult to debug. When client-side queries return empty results, always verify the auth session is present on the client by calling `supabase.auth.getSession()` in the browser console before investigating RLS policies. + +The `storageKey` option in `@supabase/ssr` controls where the auth token is stored in cookies/localStorage. Every client factory (server, browser, middleware) must use the same `storageKey` value, or they will operate in isolated auth contexts. This is not validated at build time or runtime -- mismatches produce silent failures. diff --git a/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql b/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql new file mode 100644 index 0000000..606c844 --- /dev/null +++ b/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql @@ -0,0 +1,39 @@ +-- Migration: dashboard_ui_columns +-- Adds columns and types needed by the new dashboard UI components: +-- - squad_status enum + status column on squads +-- - tags array on tasks +-- - status_reason and status_since on agents + +BEGIN; + +-- 1. Create squad_status enum +CREATE TYPE squad_status AS ENUM ('active', 'paused', 'archived'); + +-- 2. Add status column to squads table +ALTER TABLE squads + ADD COLUMN status squad_status NOT NULL DEFAULT 'active'; + +-- 3. Add tags array to tasks table +ALTER TABLE tasks + ADD COLUMN tags text[] DEFAULT '{}'; + +-- 4. Add status_reason to agents (general-purpose reason, supplements blocked_reason) +ALTER TABLE agents + ADD COLUMN status_reason text; + +-- 5. Add status_since to agents (tracks when current status was set) +ALTER TABLE agents + ADD COLUMN status_since timestamptz; + +-- 6. Index on squads(status) for filtered queries +CREATE INDEX idx_squads_status ON squads (status); + +-- 7. GIN index on tasks(tags) for array containment queries (@>, &&) +CREATE INDEX idx_tasks_tags ON tasks USING GIN (tags); + +-- 8. Backfill status_since from updated_at for existing agent rows +UPDATE agents + SET status_since = updated_at + WHERE status_since IS NULL; + +COMMIT; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 913471f..8417f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.10)(react@19.2.3) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -2046,6 +2049,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-next@16.1.6: resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} peerDependencies: @@ -2743,13 +2750,37 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -2781,6 +2812,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -3146,12 +3198,18 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -5579,6 +5637,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.6 @@ -6391,8 +6451,17 @@ snapshots: dependencies: semver: 7.7.3 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 @@ -6410,6 +6479,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -6505,6 +6631,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -6926,6 +7110,17 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -6943,6 +7138,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} diff --git a/ralph/prd/phase-02-dashboard-core.md b/ralph/prd/phase-02-dashboard-core.md index b3e5e7e..be3e3aa 100644 --- a/ralph/prd/phase-02-dashboard-core.md +++ b/ralph/prd/phase-02-dashboard-core.md @@ -143,6 +143,12 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, - [ ] `apps/web/src/components/organisms/__tests__/Header.test.tsx` - [ ] `apps/web/src/components/organisms/__tests__/AgentSidebar.test.tsx` - [ ] `apps/web/src/components/organisms/__tests__/LiveFeed.test.tsx` +- [ ] **VERIFY: Layout Component Tests** — Run and check coverage: + - `pnpm test --filter web -- --run src/components/templates/__tests__/DashboardLayout` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/Header` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/AgentSidebar` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/LiveFeed` + - All tests must pass before continuing **Kanban Components:** - [ ] `apps/web/src/components/organisms/__tests__/KanbanBoard.test.tsx`: @@ -172,6 +178,12 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, it('matches TaskCard dimensions', () => { ... }) }) ``` +- [ ] **VERIFY: Kanban Component Tests** — Run and check coverage: + - `pnpm test --filter web -- --run src/components/organisms/__tests__/KanbanBoard` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/KanbanColumn` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/TaskCard` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/TaskCardSkeleton` + - All tests must pass before continuing **Panel Components:** - [ ] `apps/web/src/components/templates/__tests__/TaskDetailPanel.test.tsx`: @@ -191,6 +203,12 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, }) ``` - [ ] `apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx` +- [ ] **VERIFY: Panel Component Tests** — Run and check coverage: + - `pnpm test --filter web -- --run src/components/templates/__tests__/TaskDetailPanel` + - `pnpm test --filter web -- --run src/components/organisms/__tests__/CommentThread` + - `pnpm test --filter web -- --run src/components/molecules/__tests__/CommentInput` + - `pnpm test --filter web -- --run src/components/templates/__tests__/AgentProfilePanel` + - All tests must pass before continuing **View Components:** - [ ] `apps/web/src/components/organisms/__tests__/GroupedTaskView.test.tsx` @@ -224,6 +242,10 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, it('removes param when value is default', () => { ... }) }) ``` +- [ ] **VERIFY: Hook Tests** — Run and check coverage: + - `pnpm test --filter web -- --run src/hooks/__tests__` + - `pnpm test --filter web -- --coverage src/hooks` + - Coverage must be >= 90% for hooks #### Integration Tests (Vitest) - [ ] `tests/integration/api/onboarding.test.ts`: @@ -313,6 +335,10 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, await expect(page2.getByText('New Task')).toBeVisible({ timeout: 1000 }) }) ``` +- [ ] **VERIFY: E2E Tests** — Run Playwright tests: + - `pnpm --filter web test:e2e tests/e2e/onboarding` + - `pnpm --filter web test:e2e tests/e2e/dashboard` + - All E2E tests must pass before continuing #### AI/LLM Tests (Live API) - [ ] `tests/ai/onboarding-squad-design.test.ts`: @@ -388,6 +414,10 @@ Build the main dashboard layout with Kanban board, agent sidebar, activity feed, - [ ] Component tests: 80%+ coverage - [ ] Hook tests: 90%+ coverage - [ ] Run `pnpm test:coverage` and verify thresholds met +- [ ] **VERIFY: Coverage Thresholds** — Final coverage check: + - `pnpm --filter web test:coverage` + - Components >= 80%, Hooks >= 90% + - If thresholds not met, add tests for uncovered code before continuing ### 2.11 Verify Dashboard - [ ] Onboarding flow creates squad and agents successfully diff --git a/skills/mission-control/SKILL.md b/skills/mission-control/SKILL.md index 3bb32e1..9b70d1d 100644 --- a/skills/mission-control/SKILL.md +++ b/skills/mission-control/SKILL.md @@ -1,243 +1,809 @@ +# Mission Control Runtime Skill + +API client for Mission Control agent operations. Handles heartbeats, task management, notifications, and team collaboration. + --- -name: mission-control -description: "Mission Control heartbeat check-in, task coordination, and team communication for AI agent squads. Activate on every heartbeat poll to report status, fetch assignments, process notifications, and sync configuration. Required for agents managed through the Mission Control dashboard." -metadata: { "openclaw": { "emoji": "📡", "requires": { "env": ["MISSION_CONTROL_API_KEY"] }, "primaryEnv": "MISSION_CONTROL_API_KEY" } } + +## Metadata + +| Field | Value | +|-------|-------| +| name | mission-control | +| description | Mission Control API client for agent operations | +| version | 1.0.0 | +| author | Mission Control | + --- -# Mission Control +## Environment Variables -## Standing Orders +| Variable | Required | Description | +|----------|----------|-------------| +| `MISSION_CONTROL_API_KEY` | Yes | API key for authentication (format: `mc_...`) | +| `MISSION_CONTROL_AGENT_NAME` | Yes | Name of this agent (e.g., "Lead", "Writer") | +| `MISSION_CONTROL_API_URL` | No | Base URL (default: `https://missioncontrol.ai`) | -On EVERY heartbeat or check-in, you MUST complete this sequence: +These are set during squad setup and should be available in the agent's environment. -1. **POST /api/heartbeat** — report your status -2. **Process notifications** from the heartbeat response -3. **GET /api/tasks?assigned_to=$AGENT_NAME** — check for work -4. Act on any new or updated tasks +--- -This is your duty cycle. Never skip it. If no tasks need attention after completing these steps, reply `HEARTBEAT_OK`. +## Trigger -## Authentication +Activate this skill when the agent needs to: -Set these variables for all requests: +- Check in with Mission Control (heartbeat) +- Retrieve or update tasks +- Communicate with team members +- Check notifications +- Sync configuration + +Common trigger phrases: +- "Check in with Mission Control" +- "What are my tasks?" +- "Send a message to the team" +- "Check for notifications" +- "Update task status" + +--- + +## API Authentication + +All API requests require authentication headers: ```bash -API_URL="${MISSION_CONTROL_API_URL:-https://missioncontrol.ai}" API_KEY="$MISSION_CONTROL_API_KEY" AGENT_NAME="$MISSION_CONTROL_AGENT_NAME" -``` - -Standard headers on every request: +API_URL="${MISSION_CONTROL_API_URL:-https://missioncontrol.ai}" -``` -Authorization: Bearer $API_KEY -X-Agent-Name: $AGENT_NAME -Content-Type: application/json +# Standard headers for all requests +HEADERS=( + -H "Authorization: Bearer $API_KEY" + -H "X-Agent-Name: $AGENT_NAME" + -H "Content-Type: application/json" +) ``` +--- + ## Heartbeat Workflow +The heartbeat is the primary check-in mechanism. It should run on a schedule (configured during setup). + ### 1. Send Heartbeat ```bash -curl -s -X POST "$API_URL/api/heartbeat" \ - -H "Authorization: Bearer $API_KEY" \ - -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" \ - -d '{"status":"idle"}' +RESPONSE=$(curl -s -X POST "$API_URL/api/heartbeat" \ + "${HEADERS[@]}" \ + -d '{"status": "idle"}') ``` -Status values: `idle`, `working`, `blocked`. - -Response: +**Request Body:** +```json +{ + "status": "idle" +} +``` +**Response:** ```json { "success": true, "notifications": [ - { "id": "uuid", "type": "mention", "title": "...", "body": "...", "task_id": "uuid" } + { + "id": "notif-uuid", + "type": "mention", + "title": "You were mentioned", + "body": "@Writer please review the draft", + "task_id": "task-uuid", + "created_at": "2024-01-15T10:30:00Z" + } ], - "soul_md_sync": { "required": false, "hash": "sha256", "content": "..." } + "soul_md_sync": { + "required": true, + "hash": "sha256-hash", + "content": "# Updated SOUL.md content..." + } } ``` ### 2. Process Notifications -For each notification in the response: +For each notification returned: -- **mention** — someone @mentioned you; check the referenced task and respond -- **task_assigned** — new task assigned to you; review and begin work -- **task_updated** — a task you follow changed; check for relevant updates -- **squad_message** — team-wide message; read and acknowledge if relevant +```bash +# Parse notifications from response +NOTIFICATIONS=$(echo "$RESPONSE" | jq -r '.notifications[]') + +for NOTIFICATION in $NOTIFICATIONS; do + NOTIF_ID=$(echo "$NOTIFICATION" | jq -r '.id') + NOTIF_TYPE=$(echo "$NOTIFICATION" | jq -r '.type') + NOTIF_TITLE=$(echo "$NOTIFICATION" | jq -r '.title') + NOTIF_BODY=$(echo "$NOTIFICATION" | jq -r '.body') + + # Process based on type + case "$NOTIF_TYPE" in + "mention") + # Someone mentioned you - check the referenced task + ;; + "task_assigned") + # A new task was assigned to you + ;; + "task_updated") + # A task you're involved with was updated + ;; + "squad_message") + # Team-wide message + ;; + esac +done +``` -Mark each notification as delivered: +**Notification Types:** + +| Type | Description | Action | +|------|-------------|--------| +| `mention` | Someone @mentioned you in a comment | Check the task and respond if needed | +| `task_assigned` | A task was assigned to you | Review task details and start work | +| `task_updated` | A task you follow was updated | Check for relevant changes | +| `squad_message` | Message to the whole squad | Read and acknowledge if relevant | +| `soul_sync` | Your SOUL.md was updated | Sync will happen automatically | + +### 3. Mark Notifications Delivered + +After processing each notification, mark it as delivered: ```bash curl -s -X PATCH "$API_URL/api/notifications/$NOTIF_ID" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" -d '{"delivered":true}' + "${HEADERS[@]}" \ + -d '{"delivered": true}' ``` -### 3. Sync SOUL.md +**Request Body:** +```json +{ + "delivered": true +} +``` -If `soul_md_sync.required` is `true`, write the new content to your SOUL.md: +**Response:** +```json +{ + "success": true, + "notification": { + "id": "notif-uuid", + "delivered": true, + "delivered_at": "2024-01-15T10:35:00Z" + } +} +``` + +### 4. Sync SOUL.md (if required) + +If `soul_md_sync.required` is true: ```bash -echo "$SOUL_CONTENT" > "$HOME/.openclaw/sessions/$AGENT_NAME/SOUL.md" +SYNC_REQUIRED=$(echo "$RESPONSE" | jq -r '.soul_md_sync.required') + +if [ "$SYNC_REQUIRED" = "true" ]; then + NEW_CONTENT=$(echo "$RESPONSE" | jq -r '.soul_md_sync.content') + NEW_HASH=$(echo "$RESPONSE" | jq -r '.soul_md_sync.hash') + + # Write the new SOUL.md + SOUL_PATH="$HOME/.openclaw/sessions/$AGENT_NAME/SOUL.md" + echo "$NEW_CONTENT" > "$SOUL_PATH" + + # Store hash for comparison + echo "$NEW_HASH" > "$SOUL_PATH.hash" + + echo "SOUL.md updated from Mission Control" +fi ``` -### 4. Fetch Tasks +--- + +## Task APIs + +### Get Assigned Tasks + +Retrieve tasks assigned to this agent: ```bash -curl -s "$API_URL/api/tasks?assigned_to=$AGENT_NAME" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" +curl -s -X GET "$API_URL/api/tasks?assigned_to=$AGENT_NAME" \ + "${HEADERS[@]}" +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `assigned_to` | string | Filter by assignee name | +| `status` | string | Filter by status: `inbox`, `assigned`, `in_progress`, `review`, `done`, `blocked` | + +**Response:** +```json +{ + "data": [ + { + "id": "task-uuid", + "title": "Write blog post", + "description": "Create a 1000 word blog post about...", + "status": "inbox", + "priority": "high", + "position": 0, + "created_at": "2024-01-15T09:00:00Z", + "updated_at": "2024-01-15T09:00:00Z", + "assignees": [ + { + "id": "agent-uuid", + "name": "Writer", + "assigned_at": "2024-01-15T09:00:00Z" + } + ] + } + ] +} ``` -Response: `{ "data": [{ "id", "title", "description", "status", "priority", "assignees" }], "meta": { "count", "timestamp" } }` +### Get Task Details -## Task Management +Get full details for a specific task: + +```bash +curl -s -X GET "$API_URL/api/tasks/$TASK_ID" \ + "${HEADERS[@]}" +``` + +**Response:** +```json +{ + "data": { + "id": "task-uuid", + "title": "Write blog post", + "description": "Create a 1000 word blog post about...", + "status": "in_progress", + "priority": "high", + "position": 0, + "created_at": "2024-01-15T09:00:00Z", + "updated_at": "2024-01-15T10:00:00Z", + "assignees": [ + { + "id": "agent-uuid", + "name": "Writer", + "assigned_at": "2024-01-15T09:00:00Z" + }, + { + "id": "agent-uuid-2", + "name": "Lead", + "assigned_at": "2024-01-15T09:00:00Z" + } + ], + "comments": [ + { + "id": "comment-uuid", + "author_type": "agent", + "author_name": "Lead", + "content": "Please focus on the technical aspects", + "created_at": "2024-01-15T09:30:00Z" + } + ] + } +} +``` ### Update Task Status +Update the status of a task: + ```bash curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" -d '{"status":"in_progress"}' + "${HEADERS[@]}" \ + -d '{"status": "in_progress"}' ``` -Valid statuses: `inbox`, `assigned`, `in_progress`, `review`, `done`, `blocked`. +**Request Body:** +```json +{ + "status": "in_progress" +} +``` -Priorities: `low`, `normal`, `high`, `urgent`. +**Valid Status Transitions:** -### Create Task +| From | To | +|------|-----| +| `inbox` | `assigned`, `in_progress` | +| `assigned` | `in_progress`, `blocked`, `inbox` | +| `in_progress` | `review`, `done`, `blocked`, `assigned` | +| `review` | `done`, `in_progress` | +| `blocked` | `in_progress`, `assigned` | +| `done` | `in_progress` (reopen) | + +**Response:** +```json +{ + "data": { + "id": "task-uuid", + "title": "Write blog post", + "description": "Create a 1000 word blog post about...", + "status": "in_progress", + "priority": "high", + "position": 0, + "created_at": "2024-01-15T09:00:00Z", + "updated_at": "2024-01-15T10:30:00Z", + "assignees": [ + { + "id": "agent-uuid", + "name": "Writer", + "assigned_at": "2024-01-15T09:00:00Z" + } + ], + "comments": [] + } +} +``` + +### Update Task Details + +Update title or description: + +```bash +curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ + "${HEADERS[@]}" \ + -d '{ + "title": "Updated title", + "description": "Updated description..." + }' +``` + +### Create New Task + +Create a task for yourself or others: ```bash curl -s -X POST "$API_URL/api/tasks" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" \ - -d '{"title":"...","description":"...","priority":"normal"}' + "${HEADERS[@]}" \ + -d '{ + "title": "Review draft document", + "description": "Please review the attached document for accuracy", + "priority": "normal" + }' +``` + +**Request Body:** +```json +{ + "title": "Task title", + "description": "Task description", + "priority": "low" | "normal" | "high" | "urgent" +} ``` -### Add Comment +**Response:** +```json +{ + "data": { + "id": "new-task-uuid", + "title": "Review draft document", + "description": "Please review the attached document for accuracy", + "status": "inbox", + "priority": "normal", + "position": 0, + "created_at": "2024-01-15T11:00:00Z", + "updated_at": "2024-01-15T11:00:00Z" + } +} +``` + +--- + +## Comment API + +### Add Comment to Task ```bash curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" \ - -d '{"content":"Done with draft. @Lead ready for review."}' + "${HEADERS[@]}" \ + -d '{"content": "I have completed the first draft. @Lead please review."}' +``` + +**Request Body:** +```json +{ + "content": "Comment text with optional @mentions" +} +``` + +**Response:** +```json +{ + "success": true, + "comment": { + "id": "comment-uuid", + "author": "Writer", + "content": "I have completed the first draft. @Lead please review.", + "created_at": "2024-01-15T14:00:00Z", + "mentions": ["Lead"] + } +} +``` + +### Get Task Comments + +```bash +curl -s -X GET "$API_URL/api/tasks/$TASK_ID/comments" \ + "${HEADERS[@]}" +``` + +**Response:** +```json +{ + "data": [ + { + "id": "comment-uuid", + "author_type": "agent", + "author_name": "Writer", + "content": "First draft ready", + "created_at": "2024-01-15T14:00:00Z" + } + ] +} ``` -Use `@AgentName` in comments to send notifications. +--- -## Team Communication +## Squad Chat API -### Squad Chat +Send messages to the entire squad: ```bash curl -s -X POST "$API_URL/api/squad-chat" \ - -H "Authorization: Bearer $API_KEY" -H "X-Agent-Name: $AGENT_NAME" \ - -H "Content-Type: application/json" \ - -d '{"message":"Starting work on the blog posts today."}' + "${HEADERS[@]}" \ + -d '{"message": "Good morning team! Starting work on the blog posts today."}' +``` + +**Request Body:** +```json +{ + "message": "Message to the squad" +} +``` + +**Response:** +```json +{ + "success": true, + "message": { + "id": "msg-uuid", + "author": "Writer", + "content": "Good morning team! Starting work on the blog posts today.", + "created_at": "2024-01-15T09:00:00Z" + } +} +``` + +--- + +## Activity API + +Get recent team activity: + +```bash +curl -s -X GET "$API_URL/api/activities?limit=20" \ + "${HEADERS[@]}" +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | number | Max results (default: 20, max: 100) | +| `since` | string | ISO timestamp - only activities after this time | +| `agent` | string | Filter by agent name | +| `type` | string | Filter by type: `task`, `comment`, `status`, `message` | + +**Response:** +```json +{ + "data": [ + { + "id": "activity-uuid", + "type": "task_status_changed", + "agent": "Writer", + "description": "Writer moved \"Write blog post\" to In Progress", + "task_id": "task-uuid", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "activity-uuid-2", + "type": "comment_added", + "agent": "Lead", + "description": "Lead commented on \"Write blog post\"", + "task_id": "task-uuid", + "created_at": "2024-01-15T10:15:00Z" + } + ], + "total": 45 +} +``` + +**Activity Types:** + +| Type | Description | +|------|-------------| +| `task_created` | A new task was created | +| `task_status_changed` | Task status was updated | +| `task_assigned` | Task was assigned to agent(s) | +| `comment_added` | Comment was added to a task | +| `squad_message` | Message sent to squad chat | +| `agent_online` | Agent came online (heartbeat) | +| `agent_offline` | Agent went offline (missed heartbeats) | + +--- + +## Collaboration Guidelines + +### Using @Mentions + +Mention other agents to notify them: + +``` +@Lead I need your review on this +@Writer can you help with the technical section? +@Social please schedule this for posting +``` + +When you @mention someone: +- They receive a notification on their next heartbeat +- The mention is linked to the task/comment for context +- They can see the full conversation thread + +### Status Updates + +Keep the team informed with regular status updates: + +```bash +# When starting a task +curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ + "${HEADERS[@]}" \ + -d '{"status": "in_progress"}' + +# Add context via comment +curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ + "${HEADERS[@]}" \ + -d '{"content": "Starting work on this now. Estimated completion in 2 hours."}' ``` ### Handoff Protocol When passing work to another agent: -1. PATCH task status to `review` -2. POST a comment explaining what was done and what is needed -3. @mention the receiving agent + +1. Update the task status to `review` or appropriate state +2. Add a comment explaining what was done and what's needed +3. Mention the receiving agent +4. Update assignees if needed + +```bash +# Complete your part +curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ + "${HEADERS[@]}" \ + -d '{"status": "review"}' + +# Handoff with context +curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ + "${HEADERS[@]}" \ + -d '{"content": "Draft complete. @Lead ready for your review. Key points: 1) Focused on technical benefits 2) Added code examples 3) Kept under 1000 words as requested."}' +``` ### Blocking Issues -When blocked: PATCH status to `blocked`, POST a comment explaining the blocker, @mention whoever can unblock you. +When you're blocked: -## Rate Limits +1. Update task status to indicate the block +2. Create a comment explaining the blocker +3. Mention whoever can unblock you +4. Optionally create a sub-task for the blocking issue -| Endpoint | Limit | -|----------|-------| -| POST /api/heartbeat | 10/min | -| /api/tasks* | 30/min | -| All other | 60/min | +```bash +curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ + "${HEADERS[@]}" \ + -d '{"content": "Blocked: Need API access credentials to continue. @Lead can you provide these?"}' +``` -On 429 responses, wait the `Retry-After` header value before retrying. +### Daily Rhythm -## Error Codes +Recommended heartbeat workflow: -| Code | Meaning | -|------|---------| -| 401 | Invalid or missing API key | -| 403 | Agent not authorized for this squad | -| 404 | Resource not found | -| 429 | Rate limited — wait and retry | +1. **On each heartbeat**: Process notifications, respond to urgent mentions +2. **Start of work session**: Check assigned tasks, prioritize +3. **During work**: Update task statuses, add progress comments +4. **End of session**: Summary comment on in-progress tasks -## Setup +--- -### 1. Install the skill +## Error Handling -Copy to your managed skills directory: +### HTTP Status Codes -```bash -cp -r skills/mission-control/ ~/.openclaw/skills/mission-control/ +| Code | Meaning | Action | +|------|---------|--------| +| 200 | Success | Process response | +| 201 | Created | Resource created successfully | +| 400 | Bad Request | Check request body/parameters | +| 401 | Unauthorized | Check API key is correct | +| 403 | Forbidden | Agent doesn't have permission | +| 404 | Not Found | Resource doesn't exist | +| 429 | Rate Limited | Wait and retry (check Retry-After header) | +| 500 | Server Error | Retry after a delay | + +### Rate Limiting + +API requests are rate limited per agent: + +| Endpoint | Limit | +|----------|-------| +| `POST /api/heartbeat` | 10/minute | +| `GET/POST/PATCH /api/tasks*` | 30/minute | +| All other endpoints | 60/minute | + +When rate limited, the response includes: +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 30 ``` -Then copy the heartbeat template to each agent's workspace: +Wait the specified seconds before retrying. + +### Network Errors ```bash -cp ~/.openclaw/skills/mission-control/HEARTBEAT.md /path/to/agent/workspace/HEARTBEAT.md +# Retry logic for transient failures +MAX_RETRIES=3 +RETRY_DELAY=5 + +for i in $(seq 1 $MAX_RETRIES); do + RESPONSE=$(curl -s -w "\n%{http_code}" ...) + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then + break + elif [ "$HTTP_CODE" = "429" ]; then + RETRY_AFTER=$(echo "$RESPONSE" | grep -i "Retry-After" | cut -d: -f2) + sleep ${RETRY_AFTER:-30} + elif [ "$i" -lt "$MAX_RETRIES" ]; then + sleep $RETRY_DELAY + RETRY_DELAY=$((RETRY_DELAY * 2)) + fi +done ``` -The HEARTBEAT.md tells OpenClaw to activate this skill on every heartbeat poll. +--- -### 2. Configure in `openclaw.json` +## Troubleshooting -```json -{ - "skills": { - "entries": { - "mission-control": { - "enabled": true, - "apiKey": "mc_your_api_key_here", - "env": { - "MISSION_CONTROL_AGENT_NAME": "Lead", - "MISSION_CONTROL_API_URL": "https://your-instance.vercel.app" - } - } - } - } -} -``` +### Heartbeat Failing -Set `MISSION_CONTROL_AGENT_NAME` to this agent's name (must match exactly in Mission Control). +1. Check environment variables are set: + ```bash + echo $MISSION_CONTROL_API_KEY + echo $MISSION_CONTROL_AGENT_NAME + ``` -Required environment variables (provided via config above or system env): +2. Test API connectivity: + ```bash + curl -s "$API_URL/api/health" + ``` -| Variable | Required | Description | -|----------|----------|-------------| -| `MISSION_CONTROL_API_KEY` | Yes | API key (format: `mc_...`). Set via `apiKey` above. | -| `MISSION_CONTROL_AGENT_NAME` | Yes | Agent name (e.g. `Lead`, `Writer`, `Social`) | -| `MISSION_CONTROL_API_URL` | No | Base URL (default: `https://missioncontrol.ai`) | +3. Verify API key is valid: + ```bash + curl -s -o /dev/null -w "%{http_code}" "$API_URL/api/heartbeat" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: $MISSION_CONTROL_AGENT_NAME" \ + -X POST -d '{}' + ``` -### 3. (Optional) Add cron for guaranteed check-ins +### Notifications Not Arriving -The skill activates automatically on heartbeat polls. For extra reliability, add a cron entry per agent: +1. Check heartbeat is running (cron job active) +2. Verify agent name matches exactly (case-sensitive) +3. Check notification wasn't already marked delivered -```json -{ - "agents": { - "list": [{ - "id": "lead", - "cron": [{ - "name": "mc-checkin", - "schedule": { "kind": "every", "everyMs": 120000 }, - "payload": { - "kind": "agentTurn", - "message": "Check in with Mission Control now. Use the mission-control skill." - }, - "sessionTarget": "isolated" - }] - }] - } -} +### SOUL.md Not Syncing + +1. Check heartbeat response includes `soul_md_sync` +2. Verify write permissions to session directory +3. Compare local hash with server hash + +### Permission Errors + +1. Verify agent belongs to the squad +2. Check agent has permission for the operation +3. Ensure task belongs to the same squad + +--- + +## Quick Reference + +### Complete Heartbeat Example + +```bash +#!/bin/bash +# Full heartbeat workflow + +API_URL="${MISSION_CONTROL_API_URL:-https://missioncontrol.ai}" +API_KEY="$MISSION_CONTROL_API_KEY" +AGENT_NAME="$MISSION_CONTROL_AGENT_NAME" + +# Send heartbeat +RESPONSE=$(curl -s -X POST "$API_URL/api/heartbeat" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"status": "idle"}') + +# Process notifications +echo "$RESPONSE" | jq -r '.notifications[]?.id' | while read NOTIF_ID; do + if [ -n "$NOTIF_ID" ]; then + # Mark as delivered + curl -s -X PATCH "$API_URL/api/notifications/$NOTIF_ID" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"delivered": true}' + fi +done + +# Sync SOUL.md if needed +SYNC_REQUIRED=$(echo "$RESPONSE" | jq -r '.soul_md_sync.required // false') +if [ "$SYNC_REQUIRED" = "true" ]; then + SOUL_PATH="$HOME/.openclaw/sessions/$AGENT_NAME/SOUL.md" + echo "$RESPONSE" | jq -r '.soul_md_sync.content' > "$SOUL_PATH" + echo "SOUL.md updated" +fi + +echo "Heartbeat complete" ``` -No modifications to OpenClaw source code are required. This skill works with stock OpenClaw. +### Task Lifecycle Example + +```bash +#!/bin/bash +# Complete task workflow + +# 1. Get my tasks +TASKS=$(curl -s -X GET "$API_URL/api/tasks?assigned_to=$AGENT_NAME&status=inbox" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME") + +# 2. Pick first task +TASK_ID=$(echo "$TASKS" | jq -r '.data[0].id') + +# 3. Start working +curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"status": "in_progress"}' + +# 4. Add progress update +curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"content": "Starting work on this task now."}' + +# ... do work ... + +# 5. Complete task +curl -s -X PATCH "$API_URL/api/tasks/$TASK_ID" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"status": "done"}' + +# 6. Final comment +curl -s -X POST "$API_URL/api/tasks/$TASK_ID/comments" \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-Agent-Name: $AGENT_NAME" \ + -H "Content-Type: application/json" \ + -d '{"content": "Task complete. Results: ..."}' +``` diff --git a/tests/integration/openclaw/agents/Lead/HEARTBEAT.md b/tests/integration/openclaw/agents/Lead/HEARTBEAT.md index 0ec381c..2a5f0e0 100644 --- a/tests/integration/openclaw/agents/Lead/HEARTBEAT.md +++ b/tests/integration/openclaw/agents/Lead/HEARTBEAT.md @@ -1,14 +1,40 @@ -# Standing Orders +# Mission Control Heartbeat — MANDATORY -You have the mission-control skill installed. Before executing its Standing Orders, run: +YOU MUST execute these steps on EVERY heartbeat. Do NOT skip any step. Do NOT reply HEARTBEAT_OK without completing step 1. + +## Step 1: Check In (REQUIRED) + +Execute this command using bash: ```bash -export MISSION_CONTROL_AGENT_NAME="Lead" +curl -s -X POST "$MISSION_CONTROL_API_URL/api/heartbeat" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Lead" \ + -H "Content-Type: application/json" \ + -d '{"status": "idle"}' ``` -Then execute the mission-control skill's Standing Orders NOW: +If this fails or returns an error, report the error. Do NOT reply HEARTBEAT_OK. + +## Step 2: Process Response + +From the heartbeat response JSON: +- If `soul_md_sync.required` is true: write the `content` field to your SOUL.md file +- If `notifications` array is non-empty: read each notification and act on it + +## Step 3: Check Tasks + +```bash +curl -s "$MISSION_CONTROL_API_URL/api/tasks" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Lead" +``` -1. POST /api/heartbeat to report your status -2. GET /api/tasks to check for new assignments +- If you have tasks with status "inbox": pick up the first one by PATCHing status to "in_progress" +- If you have tasks "in_progress": post a progress comment +- If no tasks assigned to you: HEARTBEAT_OK -This is a required action. Do not reply HEARTBEAT_OK until you have completed both API calls. +## Status Values +- "idle" — No active work +- "active" — Working on a task +- "blocked" — Cannot proceed diff --git a/tests/integration/openclaw/agents/Social/HEARTBEAT.md b/tests/integration/openclaw/agents/Social/HEARTBEAT.md index 2a69b4c..e20d154 100644 --- a/tests/integration/openclaw/agents/Social/HEARTBEAT.md +++ b/tests/integration/openclaw/agents/Social/HEARTBEAT.md @@ -1,14 +1,40 @@ -# Standing Orders +# Mission Control Heartbeat — MANDATORY -You have the mission-control skill installed. Before executing its Standing Orders, run: +YOU MUST execute these steps on EVERY heartbeat. Do NOT skip any step. Do NOT reply HEARTBEAT_OK without completing step 1. + +## Step 1: Check In (REQUIRED) + +Execute this command using bash: ```bash -export MISSION_CONTROL_AGENT_NAME="Social" +curl -s -X POST "$MISSION_CONTROL_API_URL/api/heartbeat" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Social" \ + -H "Content-Type: application/json" \ + -d '{"status": "idle"}' ``` -Then execute the mission-control skill's Standing Orders NOW: +If this fails or returns an error, report the error. Do NOT reply HEARTBEAT_OK. + +## Step 2: Process Response + +From the heartbeat response JSON: +- If `soul_md_sync.required` is true: write the `content` field to your SOUL.md file +- If `notifications` array is non-empty: read each notification and act on it + +## Step 3: Check Tasks + +```bash +curl -s "$MISSION_CONTROL_API_URL/api/tasks" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Social" +``` -1. POST /api/heartbeat to report your status -2. GET /api/tasks to check for new assignments +- If you have tasks with status "inbox": pick up the first one by PATCHing status to "in_progress" +- If you have tasks "in_progress": post a progress comment +- If no tasks assigned to you: HEARTBEAT_OK -This is a required action. Do not reply HEARTBEAT_OK until you have completed both API calls. +## Status Values +- "idle" — No active work +- "active" — Working on a task +- "blocked" — Cannot proceed diff --git a/tests/integration/openclaw/agents/Writer/HEARTBEAT.md b/tests/integration/openclaw/agents/Writer/HEARTBEAT.md index f91f7fe..3c1c521 100644 --- a/tests/integration/openclaw/agents/Writer/HEARTBEAT.md +++ b/tests/integration/openclaw/agents/Writer/HEARTBEAT.md @@ -1,14 +1,40 @@ -# Standing Orders +# Mission Control Heartbeat — MANDATORY -You have the mission-control skill installed. Before executing its Standing Orders, run: +YOU MUST execute these steps on EVERY heartbeat. Do NOT skip any step. Do NOT reply HEARTBEAT_OK without completing step 1. + +## Step 1: Check In (REQUIRED) + +Execute this command using bash: ```bash -export MISSION_CONTROL_AGENT_NAME="Writer" +curl -s -X POST "$MISSION_CONTROL_API_URL/api/heartbeat" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Writer" \ + -H "Content-Type: application/json" \ + -d '{"status": "idle"}' ``` -Then execute the mission-control skill's Standing Orders NOW: +If this fails or returns an error, report the error. Do NOT reply HEARTBEAT_OK. + +## Step 2: Process Response + +From the heartbeat response JSON: +- If `soul_md_sync.required` is true: write the `content` field to your SOUL.md file +- If `notifications` array is non-empty: read each notification and act on it + +## Step 3: Check Tasks + +```bash +curl -s "$MISSION_CONTROL_API_URL/api/tasks" \ + -H "Authorization: Bearer $MISSION_CONTROL_API_KEY" \ + -H "X-Agent-Name: Writer" +``` -1. POST /api/heartbeat to report your status -2. GET /api/tasks to check for new assignments +- If you have tasks with status "inbox": pick up the first one by PATCHing status to "in_progress" +- If you have tasks "in_progress": post a progress comment +- If no tasks assigned to you: HEARTBEAT_OK -This is a required action. Do not reply HEARTBEAT_OK until you have completed both API calls. +## Status Values +- "idle" — No active work +- "active" — Working on a task +- "blocked" — Cannot proceed diff --git a/tests/integration/openclaw/config/openclaw.json b/tests/integration/openclaw/config/openclaw.json index b47ce88..1534356 100644 --- a/tests/integration/openclaw/config/openclaw.json +++ b/tests/integration/openclaw/config/openclaw.json @@ -2,71 +2,65 @@ agents: { defaults: { workspace: "/home/node/.openclaw/workspace", - // Use Haiku for cost-effective integration testing (regular agent conversations) + // Use Haiku for cost-effective integration testing model: { primary: "anthropic/claude-haiku-4-5" }, + // Heartbeat every 2 minutes using Sonnet — Haiku ignores heartbeat instructions heartbeat: { - every: "2m", - // Sonnet follows skill instructions reliably; Haiku tends to skip them - model: "anthropic/claude-sonnet-4-5", - // Ensure all heartbeat responses are delivered (default 300 char limit drops short responses) - ackMaxChars: 0 + every: "2m" }, - // Disable sandbox for integration tests + // Disable sandbox for integration tests (agents don't need file system isolation) sandbox: { mode: "off" } }, list: [ { id: "lead", name: "Lead", workspace: "/home/node/.openclaw/workspace-lead", - heartbeat: {} + heartbeat: { + model: "anthropic/claude-sonnet-4-5", + prompt: "ACTION REQUIRED — Execute this bash command immediately:\n\ncurl -s -X POST \"$MISSION_CONTROL_API_URL/api/heartbeat\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Lead\" -H \"Content-Type: application/json\" -d '{\"status\": \"idle\"}'\n\nReport the JSON response. Then check tasks:\n\ncurl -s \"$MISSION_CONTROL_API_URL/api/tasks\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Lead\"\n\nThis is a mandatory check-in. Do not skip." + } }, { id: "writer", name: "Writer", workspace: "/home/node/.openclaw/workspace-writer", - heartbeat: {} + heartbeat: { + model: "anthropic/claude-sonnet-4-5", + prompt: "ACTION REQUIRED — Execute this bash command immediately:\n\ncurl -s -X POST \"$MISSION_CONTROL_API_URL/api/heartbeat\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Writer\" -H \"Content-Type: application/json\" -d '{\"status\": \"idle\"}'\n\nReport the JSON response. Then check tasks:\n\ncurl -s \"$MISSION_CONTROL_API_URL/api/tasks\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Writer\"\n\nThis is a mandatory check-in. Do not skip." + } }, { id: "social", name: "Social", workspace: "/home/node/.openclaw/workspace-social", - heartbeat: {} + heartbeat: { + model: "anthropic/claude-sonnet-4-5", + prompt: "ACTION REQUIRED — Execute this bash command immediately:\n\ncurl -s -X POST \"$MISSION_CONTROL_API_URL/api/heartbeat\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Social\" -H \"Content-Type: application/json\" -d '{\"status\": \"idle\"}'\n\nReport the JSON response. Then check tasks:\n\ncurl -s \"$MISSION_CONTROL_API_URL/api/tasks\" -H \"Authorization: Bearer $MISSION_CONTROL_API_KEY\" -H \"X-Agent-Name: Social\"\n\nThis is a mandatory check-in. Do not skip." + } } ] }, - // Skill discovery + configuration - // load.extraDirs tells OpenClaw where to find skill files on disk - // entries configures the skill (apiKey maps to primaryEnv, env sets runtime vars) - // MISSION_CONTROL_AGENT_NAME is set per-agent via each agent's HEARTBEAT.md (can't be global) + // Skills configuration — load the mission-control skill from the shared skills directory skills: { load: { extraDirs: ["/home/node/.openclaw/skills"] - }, - entries: { - "mission-control": { - enabled: true, - apiKey: "${MISSION_CONTROL_API_KEY}", - env: { - MISSION_CONTROL_API_URL: "${MISSION_CONTROL_API_URL}" - } - } } }, - // Global env vars — available to all agents + // Environment variables injected at container runtime env: { MISSION_CONTROL_API_URL: "${MISSION_CONTROL_API_URL}", MISSION_CONTROL_API_KEY: "${MISSION_CONTROL_API_KEY}" }, - // Gateway config for Docker + // Gateway config for Docker — no auth needed in test environment gateway: { mode: "local", port: 18789 }, - // Debug logging for integration tests + // Logging for debugging integration test runs logging: { - level: "debug", - consoleLevel: "debug" + level: "info", + consoleLevel: "info" } } diff --git a/tests/integration/scripts/entrypoint-openclaw.sh b/tests/integration/scripts/entrypoint-openclaw.sh index cac2699..7276be7 100755 --- a/tests/integration/scripts/entrypoint-openclaw.sh +++ b/tests/integration/scripts/entrypoint-openclaw.sh @@ -11,12 +11,9 @@ apt-get update -qq && apt-get install -y -qq curl jq > /dev/null 2>&1 echo "[entrypoint] Setting up OpenClaw config..." bash /opt/openclaw-scripts/setup-openclaw-config.sh -# Copy real Mission Control skill files (the ones in tests/integration are placeholders) -echo "[entrypoint] Copying real Mission Control skill files..." +# Copy real Mission Control SKILL.md (the one in tests/integration is a placeholder) +echo "[entrypoint] Copying real Mission Control SKILL.md..." cp /opt/real-skill/SKILL.md /home/node/.openclaw/skills/mission-control/SKILL.md -if [ -f /opt/real-skill/HEARTBEAT.md ]; then - cp /opt/real-skill/HEARTBEAT.md /home/node/.openclaw/skills/mission-control/HEARTBEAT.md -fi # Step 2: Bootstrap Mission Control squad and get API key echo "[entrypoint] Bootstrapping Mission Control..." diff --git a/tests/integration/scripts/setup-openclaw-config.sh b/tests/integration/scripts/setup-openclaw-config.sh index ef2c98d..29a339d 100755 --- a/tests/integration/scripts/setup-openclaw-config.sh +++ b/tests/integration/scripts/setup-openclaw-config.sh @@ -33,12 +33,12 @@ cp "$SOURCE_DIR/agents/Lead/SOUL.md" "$CONFIG_DIR/workspace-lead/SOUL.md" cp "$SOURCE_DIR/agents/Writer/SOUL.md" "$CONFIG_DIR/workspace-writer/SOUL.md" cp "$SOURCE_DIR/agents/Social/SOUL.md" "$CONFIG_DIR/workspace-social/SOUL.md" -# Copy per-agent HEARTBEAT.md files (each sets MISSION_CONTROL_AGENT_NAME for skill-based heartbeat) +# Copy per-agent HEARTBEAT.md files (each has hardcoded agent name for X-Agent-Name header) cp "$SOURCE_DIR/agents/Lead/HEARTBEAT.md" "$CONFIG_DIR/workspace-lead/HEARTBEAT.md" cp "$SOURCE_DIR/agents/Writer/HEARTBEAT.md" "$CONFIG_DIR/workspace-writer/HEARTBEAT.md" cp "$SOURCE_DIR/agents/Social/HEARTBEAT.md" "$CONFIG_DIR/workspace-social/HEARTBEAT.md" -# Copy Mission Control SKILL.md (placeholder — real skill is copied by entrypoint) +# Copy Mission Control SKILL.md cp "$SOURCE_DIR/skills/mission-control/SKILL.md" "$SKILLS_DIR/SKILL.md" echo "OpenClaw config setup complete."