Merge pull request #2036 from Molecule-AI/sync/staging-to-main-2026-04-24-final

chore: promote sync-to-main-final → main (finish #1981)
This commit is contained in:
Hongming Wang 2026-04-24 11:00:41 -07:00 committed by GitHub
commit a59f1a6ce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
254 changed files with 10015 additions and 8850 deletions

View File

@ -34,7 +34,7 @@ PLUGINS_DIR= # Path to plugins/ directory (default: /plugins i
# MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions.
# MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity.
# WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume.
# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour.
MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and for the AdminAuth dev-mode escape hatch (lets the Canvas dashboard keep working after the first workspace is created, when ADMIN_TOKEN is unset). SaaS deployments MUST set MOLECULE_ENV=production.
# MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled.
# MOLECULE_ORG_ID= # SaaS only: org UUID set by control plane on tenant machines. When set, workspace provisioning auto-routes through the control plane API instead of Docker.
# CP_PROVISION_URL= # Override control plane URL for workspace provisioning (default: https://api.moleculesai.app). Only needed for testing against a non-production control plane.

View File

@ -0,0 +1,107 @@
name: Block internal-flavored paths
# Hard CI gate. Internal content (positioning, competitive briefs, sales
# playbooks, PMM/press drip, draft campaigns) lives in Molecule-AI/internal —
# this public monorepo must never re-acquire those paths. CEO directive
# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here.
#
# Failure mode without this gate: agents (PMM, Research, DevRel, Sales) drop
# briefs into the easiest path their cwd resolves to (root /research,
# /marketing, /docs/marketing) and gitignore alone won't catch a `git add -f`
# or a stale gitignore line. This workflow is the mechanical backstop.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
# Required for GitHub merge queue: the queue's pre-merge CI run on
# `gh-readonly-queue/...` refs needs this check to fire so the queue
# gets a real result instead of stalling forever AWAITING_CHECKS.
merge_group:
types: [checks_requested]
jobs:
check:
name: Block forbidden paths
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # need previous commit to diff against on push events
# For pull_request events the diff base is github.event.pull_request.base.sha,
# which may be many commits behind HEAD and therefore absent from the
# shallow clone above. Fetch it explicitly (depth=1 keeps it fast).
- name: Fetch PR base SHA (pull_request events only)
if: github.event_name == 'pull_request'
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- name: Refuse if forbidden paths appear
run: |
# Paths that must NEVER live in the public monorepo. Add to this
# list narrowly — broader patterns belong in .gitignore so day-to-day
# docs work isn't accidentally blocked.
FORBIDDEN_PATTERNS=(
"^research/"
"^marketing/"
"^docs/marketing/"
"^comment-[0-9]+\.json$"
"^test-pmm.*\.(txt|md)$"
"^tick-reflections.*\.(txt|md)$"
".*-temp\.(md|txt)$"
)
# Determine the diff base.
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
else
BASE="${{ github.event.before }}"
HEAD="${{ github.event.after }}"
fi
# Files added or modified in this change.
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch / no previous SHA — check entire tree.
CHANGED=$(git ls-tree -r --name-only HEAD)
else
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
fi
if [ -z "$CHANGED" ]; then
echo "No changed files to inspect."
exit 0
fi
OFFENDING=""
for path in $CHANGED; do
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
if echo "$path" | grep -qE "$pattern"; then
OFFENDING="${OFFENDING}${path} (matched: ${pattern})\n"
break
fi
done
done
if [ -n "$OFFENDING" ]; then
echo "::error::Forbidden internal-flavored paths detected:"
printf "$OFFENDING"
echo ""
echo "These paths belong in Molecule-AI/internal, not this public repo."
echo "See docs/internal-content-policy.md for canonical locations."
echo ""
echo "If your file is genuinely public-facing (e.g. a blog post"
echo "ready to ship), use one of these alternatives instead:"
echo " • Public-bound blog posts: docs/blog/<slug>.md"
echo " • Public-bound tutorials: docs/tutorials/<slug>.md"
echo " • Public devrel content: docs/devrel/<slug>.md"
echo ""
echo "If you legitimately need to add a new top-level path that"
echo "happens to match a forbidden pattern, edit"
echo ".github/workflows/block-internal-paths.yml and update the"
echo "FORBIDDEN_PATTERNS list with reviewer signoff."
exit 1
fi
echo "✓ No forbidden paths in this change."

View File

@ -0,0 +1,123 @@
name: Check merge_group trigger on required workflows
# Pre-merge guard against the deadlock pattern where a workflow whose
# check is in `required_status_checks` lacks a `merge_group:` trigger.
# Without it, GitHub merge queue stalls forever in AWAITING_CHECKS
# because the required check can't fire on `gh-readonly-queue/...` refs.
#
# This workflow:
# 1. Lists required status checks on the branch protection rule for `staging`
# 2. For each required check, finds the workflow that produces it (by job
# name match)
# 3. Fails if any such workflow lacks `merge_group:` in its triggers
#
# Reasoning for staging-only: main has its own CI gating model (PR review),
# but staging is what the merge queue runs on, so it's the trigger that
# matters.
on:
pull_request:
paths:
- '.github/workflows/**.yml'
- '.github/workflows/**.yaml'
push:
branches: [staging, main]
paths:
- '.github/workflows/**.yml'
- '.github/workflows/**.yaml'
# Self-listen on merge_group so the linter passes its own queue run.
merge_group:
types: [checks_requested]
jobs:
check:
name: Required workflows have merge_group trigger
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Verify merge_group trigger on required-check workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
# Branch we care about — the one merge queue runs on.
BRANCH=staging
# Pull the list of required status check contexts. If the branch
# has no protection or no required checks, exit clean — nothing
# to lint.
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
--jq '.contexts[]' 2>/dev/null || true)
if [ -z "$REQUIRED" ]; then
echo "No required status checks on ${BRANCH} — nothing to verify."
exit 0
fi
echo "Required checks on ${BRANCH}:"
echo "${REQUIRED}" | sed 's/^/ - /'
echo
# Build a map: workflow file -> set of job names declared in it.
# We use yq if available, otherwise grep the `name:` lines under
# `jobs:`. Stick with grep for portability — runner image always
# has it; yq isn't in the default image as of 2026-04.
declare -A workflow_jobs
shopt -s nullglob
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$wf" ] || continue
# Extract the workflow name (the `name:` at file root).
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
# Extract job step names from the `jobs:` block. A job step is:
# - id under `jobs:` (key with 2-space indent followed by colon)
# - the `name:` field inside that job (4-space indent)
# We collect both because required_status_checks contexts can
# match either, depending on how the workflow was authored.
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
done
# For each required check, find the workflow that produces it.
# Then verify that workflow lists merge_group as a trigger.
FAILED=0
while IFS= read -r check; do
[ -z "$check" ] && continue
owning_wf=""
for wf in "${!workflow_jobs[@]}"; do
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
owning_wf="$wf"
break
fi
done
if [ -z "$owning_wf" ]; then
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
continue
fi
# Does the workflow's trigger list include merge_group?
# Match either bare `merge_group:` line or merge_group with
# subsequent indented config (types: [checks_requested]).
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
else
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
echo "::error file=${owning_wf}:: merge_group:"
echo "::error file=${owning_wf}:: types: [checks_requested]"
FAILED=1
fi
done <<< "$REQUIRED"
if [ "$FAILED" -ne 0 ]; then
echo
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
exit 1
fi
echo
echo "All required workflows on ${BRANCH} declare merge_group triggers."

View File

@ -5,9 +5,17 @@ on:
branches: [main, staging]
pull_request:
branches: [main, staging]
# GitHub merge queue fires `merge_group` for the queue's pre-merge CI run.
# Required so the queue gets a real check result instead of a false-green
# from the absence of a triggered workflow. Safe to add unconditionally —
# the event simply doesn't fire until the queue is enabled on the branch.
merge_group:
types: [checks_requested]
# Cancel in-progress CI runs when a new commit arrives on the same ref.
# This prevents stale runs from queuing behind each other.
# This prevents stale runs from queuing behind each other. The merge_group
# refs (refs/heads/gh-readonly-queue/...) get their own concurrency group
# automatically because github.ref differs from the PR ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

View File

@ -18,6 +18,12 @@ on:
branches: [main, staging]
pull_request:
branches: [main, staging]
# GitHub merge queue fires `merge_group` for the queue's pre-merge CI run.
# Required so CodeQL Analyze checks get a real result on the queued
# commit instead of a false-green. Event only fires once merge queue is
# enabled on the target branch — safe to add unconditionally.
merge_group:
types: [checks_requested]
schedule:
# Weekly run picks up findings in code that hasn't been touched.
- cron: '30 1 * * 0'

View File

@ -1,35 +1,21 @@
name: E2E API Smoke Test
# Extracted from ci.yml so workflow-level concurrency can protect this job
# from run-level cancellation (issue #458).
#
# Problem: the job-level `concurrency.cancel-in-progress: false` in ci.yml
# prevented *sibling* E2E jobs from killing each other, but GitHub still
# cancelled the parent *workflow run* when a new push arrived. Since the job
# lived inside that run, it got cancelled too.
#
# Fix: a dedicated workflow gets its own concurrency group at the workflow
# level. New pushes to the same branch queue here instead of cancelling.
# Fast jobs (platform-build, canvas-build, etc.) stay in ci.yml and continue
# to benefit from run-level cancellation for quick feedback.
on:
push:
branches: [main]
branches: [main, staging]
paths:
- 'workspace-server/**'
- 'tests/e2e/**'
- '.github/workflows/e2e-api.yml'
pull_request:
branches: [main]
branches: [main, staging]
paths:
- 'workspace-server/**'
- 'tests/e2e/**'
- '.github/workflows/e2e-api.yml'
# Workflow-level concurrency: new runs queue rather than cancel.
# `cancel-in-progress: false` is load-bearing — without it GitHub would still
# cancel this run when the next push arrives, defeating the whole fix.
# The group key includes github.ref so PRs don't compete with main.
concurrency:
group: e2e-api-${{ github.ref }}
cancel-in-progress: false
@ -39,12 +25,6 @@ jobs:
name: E2E API Smoke Test
runs-on: ubuntu-latest
timeout-minutes: 15
# Postgres + Redis run as sibling containers via `docker run`. Could
# switch to a `services:` block now that we're on Linux, but the
# explicit start-and-wait gives us pg_isready / PING readiness checks
# that match the 30-tick timeouts the rest of the job expects. Ports
# 15432/16379 avoid collision with anything the host may already have
# on the standard ports.
env:
DATABASE_URL: postgres://dev:dev@localhost:15432/molecule?sslmode=disable
REDIS_URL: redis://localhost:16379
@ -61,12 +41,7 @@ jobs:
- name: Start Postgres (docker)
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev \
-e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=molecule \
-p 15432:5432 \
postgres:16
docker run -d --name "$PG_CONTAINER" -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule -p 15432:5432 postgres:16
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
echo "Postgres ready after ${i}s"
@ -89,6 +64,7 @@ jobs:
sleep 1
done
echo "::error::Redis did not become ready in 15s"
docker logs "$REDIS_CONTAINER" || true
exit 1
- name: Build platform
working-directory: workspace-server
@ -111,16 +87,14 @@ jobs:
cat workspace-server/platform.log || true
exit 1
- name: Assert migrations applied
# Migrations auto-run at platform boot. Fail fast if they silently
# didn't — catches future migration-author mistakes before the E2E run.
run: |
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'")
if [ "$tables" != "1" ]; then
echo "::error::Migrations did not apply — 'workspaces' table missing"
echo "::error::Migrations did not apply"
cat workspace-server/platform.log || true
exit 1
fi
echo "Migrations OK (workspaces table present)"
echo "Migrations OK"
- name: Run E2E API tests
run: bash tests/e2e/test_api.sh
- name: Dump platform log on failure

View File

@ -5,18 +5,21 @@ name: E2E Staging Canvas (Playwright)
# e2e-staging-saas.yml (which tests the API shape) by exercising the
# actual browser + canvas bundle against live staging.
#
# Triggers: push to main or PR touching canvas sources + this workflow,
# Triggers: push to main/staging or PR touching canvas sources + this workflow,
# manual dispatch, and weekly cron to catch browser/runtime drift even
# when canvas is quiet.
# Added staging to push/pull_request branches so the auto-promote gate
# check (--event push --branch staging) can see a completed run for this
# workflow — mirrors what PR #1891 does for e2e-api.yml.
on:
push:
branches: [main]
branches: [main, staging]
paths:
- 'canvas/**'
- '.github/workflows/e2e-staging-canvas.yml'
pull_request:
branches: [main]
branches: [main, staging]
paths:
- 'canvas/**'
- '.github/workflows/e2e-staging-canvas.yml'

View File

@ -5,7 +5,7 @@ name: E2E Staging SaaS (full lifecycle)
# HMA memory → activity → peers), then tears down and asserts leak-free.
#
# Why a separate workflow (not folded into ci.yml):
# - The run takes ~20 min (EC2 boot + cloudflared DNS + provision sweeps +
# - The run takes ~25-35 min (EC2 boot + cloudflared DNS + provision sweeps +
# agent bootstrap), way too slow for every PR.
# - Needs its own concurrency group so two pushes don't fight over the
# same staging org slug prefix.
@ -68,7 +68,7 @@ jobs:
e2e-staging-saas:
name: E2E Staging SaaS
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
permissions:
contents: read

View File

@ -73,7 +73,20 @@ jobs:
# - canary-verify.yml runs smoke tests against them
# - On green → canary-verify retags :staging-<sha> → :latest
# - On red → :latest stays on the prior good digest, prod is safe
- name: Build & push platform image to GHCR (staging-<sha> only)
# Every push of :staging-<sha> also retags the same digest as
# :staging-latest so staging CP (which pins TENANT_IMAGE at
# :staging-latest) picks up new builds automatically — no more manual
# Railway env-var edits. Prod's :latest retag still happens in
# canary-verify.yml after the canary fleet greenlights this digest;
# :staging-latest is strictly the "most recent main build," not a
# canary-verified promotion.
#
# Before this, TENANT_IMAGE on Railway staging was pinned to a static
# :staging-<sha> and drifted months behind (2026-04-24 incident:
# canary tenant ran :staging-a14cf86, 10 days stale, which lacked
# applyRuntimeModelEnv and caused every E2E to route hermes+openai
# through openrouter → 401). See issue filed with this PR.
- name: Build & push platform image to GHCR (staging-<sha> + staging-latest)
uses: docker/build-push-action@v6
with:
context: .
@ -82,6 +95,7 @@ jobs:
push: true
tags: |
${{ env.IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
${{ env.IMAGE_NAME }}:staging-latest
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
@ -89,7 +103,7 @@ jobs:
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify
- name: Build & push tenant image to GHCR (staging-<sha> only)
- name: Build & push tenant image to GHCR (staging-<sha> + staging-latest)
uses: docker/build-push-action@v6
with:
context: .
@ -98,6 +112,7 @@ jobs:
push: true
tags: |
${{ env.TENANT_IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
${{ env.TENANT_IMAGE_NAME }}:staging-latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Canvas uses same-origin fetches. The tenant Go platform

20
.gitignore vendored
View File

@ -120,9 +120,29 @@ backups/
# org-templates live in Molecule-AI/molecule-ai-org-template-* repos
# (including molecule-dev — no checkin exception).
# plugins live in Molecule-AI/molecule-ai-plugin-* repos.
# All three directories are populated by scripts/clone-manifest.sh
# (now auto-run by infra/scripts/setup.sh). The in-tree exception for
# molecule-dev was removed because the checked-in copy drifted from
# the standalone repo and shipped with broken !include references to
# role files that never existed in the snapshot.
/org-templates/
/plugins/
/workspace-configs-templates/
# Cloned by publish-workspace-server-image.yml so the Dockerfile's
# replace-directive path resolves. Lives in its own repo.
/molecule-ai-plugin-github-app-auth/
# Internal-flavored content lives in Molecule-AI/internal — NEVER in this
# public monorepo. Migrated 2026-04-23 (CEO directive). The CI workflow
# .github/workflows/block-internal-paths.yml enforces this; this gitignore
# is the second line of defence so accidental local writes don't reach a
# commit. See docs/internal-content-policy.md for the full rationale.
/research/
/marketing/
/docs/marketing/
# Common temp/scratch patterns agents have produced
/comment-*.json
*-temp.md
*-temp.txt
/test-pmm-*.txt
/tick-reflections-*.md

View File

@ -12,6 +12,11 @@ development workflow, conventions, and how to get your changes merged.
- **Python 3.11+** — workspace runtime
- **Docker** — infrastructure services (Postgres, Redis)
- **Git** — with hooks path set to `.githooks`
- **jq** — parses `manifest.json` during `setup.sh` to clone the
template/plugin registry. Install via `brew install jq` (macOS) or
`apt install jq` (Debian). Without it, setup.sh prints a note and
leaves the registry dirs empty (recoverable by installing jq and
re-running).
### Setup

View File

@ -261,6 +261,12 @@ cp .env.example .env
# and Temporal (:7233 gRPC, :8233 UI) on the shared
# `molecule-monorepo-net` Docker network. Temporal runs with
# no auth on localhost — dev-only; production must gate it.
#
# Also populates the template/plugin registry by cloning every repo
# listed in manifest.json into workspace-configs-templates/,
# org-templates/, and plugins/. Requires jq — install via
# `brew install jq` (macOS) or `apt install jq` (Debian). Idempotent:
# re-runs skip any target dir that's already populated.
cd workspace-server
go run ./cmd/server # applies pending migrations on first boot

View File

@ -260,6 +260,11 @@ cp .env.example .env
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
#
# 同时会根据 manifest.json 拉取所有模板/插件仓库到
# workspace-configs-templates/、org-templates/、plugins/ 三个目录。
# 需要安装 jq`brew install jq`macOS`apt install jq`Debian
# 脚本幂等:已经存在内容的目录会被跳过,可以安全重跑。
cd workspace-server
go run ./cmd/server # 首次启动会自动跑 schema_migrations 里未应用的迁移

View File

@ -26,8 +26,13 @@ const CP_URL = process.env.MOLECULE_CP_URL || "https://staging-api.moleculesai.a
const ADMIN_TOKEN = process.env.MOLECULE_ADMIN_TOKEN;
const STAGING = process.env.CANVAS_E2E_STAGING === "1";
const PROVISION_TIMEOUT_MS = 15 * 60 * 1000;
const WORKSPACE_ONLINE_TIMEOUT_MS = 10 * 60 * 1000;
// Tenant cold boot on staging regularly takes 12-15 min when the
// workspace-server Docker image isn't already cached on the AMI. Raised
// to 20 min to match tests/e2e/test_staging_full_saas.sh (PR #1930)
// after repeated "tenant provision: timed out after 900s" flakes
// were blocking staging→main syncs on 2026-04-24.
const PROVISION_TIMEOUT_MS = 20 * 60 * 1000;
const WORKSPACE_ONLINE_TIMEOUT_MS = 20 * 60 * 1000;
const TLS_TIMEOUT_MS = 3 * 60 * 1000;
async function jsonFetch(

View File

@ -0,0 +1,240 @@
---
title: "Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration"
date: "2026-04-20"
canonical: "https://docs.molecule.ai/blog/chrome-devtools-mcp"
og_title: "Give Your AI Agent Browser Superpowers with Chrome DevTools MCP"
og_description: "Chrome DevTools MCP brings AI agent browser control to Molecule AI. Every browser action is audit-attributed via org API keys. MCP browser automation with governance built in."
og_image: "/blog/chrome-devtools-mcp/chrome-devtools-mcp-social-card.png"
twitter_card: "summary_large_image"
author: "Molecule AI"
keywords:
- "AI agent browser control"
- "MCP browser automation"
- "browser automation AI agents"
- "browser automation governance"
- "Chrome DevTools MCP"
- "MCP governance layer"
- "AI agent web UI automation"
---
import { Callout } from '@/components/blog/Callout'
import { CodeBlock } from '@/components/blog/CodeBlock'
# Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration
Every AI agent platform eventually gets asked the same question: "Can it interact with a web interface?" The answer is usually some variant of "sort of — give it your credentials and hope for the best." That's not a real answer. It's a trust fall.
Chrome DevTools MCP changes this. It gives your AI agent a structured, governed interface to a real Chrome browser session — with full **MCP browser automation** capability and an audit trail that actually answers the question: "which agent touched what, and what did it do?"
This post covers what Chrome DevTools MCP is, how Molecule AI's governance layer makes it enterprise-safe, and how to put it to work in your agent fleet.
---
## What is Chrome DevTools MCP?
Chrome DevTools MCP is an integration between the [MCP (Model Context Protocol)](https://modelcontextprotocol.io) and Google Chrome's DevTools Protocol. MCP is a standardized interface layer that lets AI agents connect to external tools with consistent tooling, authentication, and telemetry. The DevTools Protocol is Chrome's native debugging interface — the same interface your browser's developer tools use to inspect pages, capture network traffic, and control the browser.
When you connect an AI agent to Chrome DevTools via MCP, you get:
- **Full CDP access** — navigate, click, type, screenshot, evaluate JavaScript, read network logs, intercept requests, read cookies and local storage
- **MCP protocol layer** — structured JSON-RPC instead of raw CDP, consistent tool naming, type-safe parameters
- **Molecule AI governance layer** — org API key attribution, audit logging, session scoping, instant revocation
The third item is what separates this from "use Puppeteer with an API key." It's the difference between browser automation AI agents and browser automation AI agents with a compliance story.
---
## The Browser Problem: Trust Falls and Black Boxes
When most teams give an AI agent browser access, the workflow looks like this:
1. Agent receives a task ("find our competitors' pricing pages")
2. Agent uses browser credentials to log into Chrome
3. Agent navigates, reads, screenshots, and reports
4. Nobody knows exactly what the agent did, which session it used, or whether credentials were exposed
This is a trust fall, not a governance model. The agent *can* do the task. But you have no audit trail if something goes wrong. No way to revoke access if the agent's behavior becomes unexpected. No attribution if you need to trace a call back to a specific integration.
The **MCP governance layer** in Molecule AI addresses all three:
- Every browser action is logged with the org API key prefix that initiated it
- Chrome sessions are token-scoped — Agent A's session is never Agent B's
- Revocation is one API call — the key stops working, the session closes, no redeploy required
---
## How MCP Browser Automation Works in Molecule AI
The integration uses Chrome's CDP over a WebSocket connection managed by the MCP server. Molecule AI's MCP server exposes a structured set of tools that map to CDP commands. Your agent calls these tools like any other MCP tool — the same interface whether you're automating Chrome, reading memory, or querying the platform API.
Here's the sequence:
1. **Workspace starts with a Chrome session attached** — the session is scoped to a specific Chrome profile or fresh browser context, isolated from other agents
2. **Agent calls MCP tools** — `cdp_navigate`, `cdp_click`, `cdp_evaluate`, `cdp_screenshot`, and others are available as structured tools with type-safe parameters
3. **Every call is audit-attributed** — the org API key prefix (e.g., `mole_a1b2`) is logged with the tool name, parameters, and result for every CDP call
4. **Session is revocable at any time** — revoke the org API key and the agent loses Chrome access immediately
### AI Agent Browser Control: What You Can Do
**Navigation and interaction:**
- `cdp_navigate` — navigate to any URL (supports `data:` and `about:` URLs via browser UI)
- `cdp_click` — click a DOM element by selector
- `cdp_type` — type text into a focused element
- `cdp_hover` — hover over a DOM element
- `cdp_scroll` — scroll an element or the page
**Inspection and debugging:**
- `cdp_screenshot` — capture a full-page or viewport screenshot
- `cdp_evaluate` — execute JavaScript in the page context
- `cdp_get_cookies` / `cdp_set_cookies` — read and write cookies for authenticated sessions
- `cdp_get_local_storage` / `cdp_set_local_storage` — read and write localStorage
**Network and performance:**
- `cdp_get_requests` — capture and filter network requests (XHR, fetch, WS)
- `cdp_block_urls` — block specific URL patterns to simulate adblocked environments
- `cdp_set_throttle` — throttle network conditions (3G, LTE, offline)
---
## Browser Automation AI Agents: Use Cases That Actually Ship
The Chrome DevTools MCP integration is most useful in workflows where browser state is the source of truth — and where audit attribution matters.
### Automated Lighthouse audits on every PR
A research agent runs a Lighthouse audit against every pull request in your repo. It navigates to the preview URL, captures the performance score, flags regressions below your threshold, and reports to the PM agent. Every audit run is logged with the org API key — your observability team can trace which agent ran which audit and when.
```bash
# Agent calls cdp_navigate to the PR preview URL
# Agent calls cdp_evaluate to run Lighthouse inline
# Agent calls cdp_screenshot to capture the score
# Agent delegates results to PM workspace
```
### Visual regression detection
An agent maintains a baseline set of screenshots for your key user flows. On every code change, it navigates to each flow, captures screenshots, and diffs against the baseline. Drift beyond your threshold opens a ticket automatically. The governance layer means your QA team can review the full history of which screenshots were captured, when, and by which agent.
### Auth scraping
An agent reads authenticated browser state from an existing Chrome session — cookies, localStorage, session tokens — and uses that state to authenticate API calls that would otherwise require separate credential management. The session is scoped; the credentials never leave the browser context.
---
## MCP Governance Layer: Why It Matters
The MCP protocol gives you tool connectivity. The governance layer is what makes it enterprise-ready.
### Per-action audit logging
Every CDP call your agent makes generates an audit log entry. The log includes:
- **Org API key prefix** — which integration made the call (e.g., `mole_a1b2`)
- **Tool name and parameters** — `cdp_navigate(url=https://...)`
- **Result or error** — success, timeout, or CDP error code
- **Timestamp and workspace ID** — for timeline reconstruction
This is the audit trail your security team will ask for in the next compliance review. It exists because Molecule AI's MCP server generates it — not because you built a custom logging pipeline.
### Token-scoped Chrome sessions
Chrome sessions are isolated per org API key. When you create an org API key for a specific integration (`lighthouse-reporter`), that key's Chrome session is separate from every other key's session. No credential cross-contamination — Agent A cannot read Agent B's authenticated state because their sessions are isolated at the MCP tool layer.
### Instant revocation without redeployment
If you need to revoke access — the integration is compromised, the agent behavior is unexpected, the contractor relationship ended — you revoke the org API key:
```bash
curl -X DELETE https://platform.moleculesai.app/org/tokens/<token-id> \
-H "Authorization: Bearer <admin-session-token>"
```
The key stops working immediately. The Chrome session is closed. The agent loses browser access before the next heartbeat. No redeploy, no container restart, no waiting for DNS cache expiration.
---
## Setting Up Chrome DevTools MCP
Chrome DevTools MCP requires a Chrome instance running with the remote debugging port enabled, and a `chromedp` or equivalent CDP client connected through Molecule AI's MCP server.
### Step 1: Enable Chrome remote debugging
Start Chrome with the `--remote-debugging-port=9222` flag:
```bash
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-debug
# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
```
### Step 2: Configure Molecule AI
In your workspace config, add the Chrome DevTools MCP server URL:
```yaml
# config.yaml
mcpServers:
- name: chrome-devtools
url: "http://localhost:9222" # CDP WebSocket endpoint
transport: cdp
```
### Step 3: Verify the connection
Your agent can now call CDP tools. Test with a simple navigation:
```
Agent: navigate to https://example.com and screenshot the page
```
The audit log should show `cdp_navigate` and `cdp_screenshot` entries attributed to the workspace's org API key prefix.
---
## What the Security Review Looks Like
When your security team asks "what does this integration actually do?", here's the answer:
**What it can do:**
- Navigate to any URL (with org API key attribution on every navigation)
- Read and write browser state (cookies, localStorage, session tokens)
- Screenshot pages and DOM elements
- Execute JavaScript in the page context
**What it can't do (by default):**
- Access the host machine beyond the Chrome sandbox
- Read files outside the browser context
- Exfiltrate session tokens across session boundaries
**What revocation looks like:**
- Revoke org API key → immediate session close
- No redeploy, no agent restart
- Audit trail shows every action taken before revocation
---
## Browser Automation Governance: The Bigger Picture
Chrome DevTools MCP is one piece of Molecule AI's broader MCP governance story. MCP is a general-purpose protocol — it connects agents to any tool that speaks CDP, stdio, or HTTP. The governance layer applies uniformly: every MCP call gets the same treatment — org API key attribution, audit logging, instant revocation.
This means you can add new MCP integrations — databases, APIs, code execution environments — with the same governance posture. The MCP protocol is the connectivity layer. Molecule AI's MCP governance layer is the control plane.
If you're evaluating AI agent platforms for browser automation governance, the question to ask is not "can it control a browser?" It's "can I audit every action, attribute every call, and revoke access in one step?" Chrome DevTools MCP with Molecule AI's MCP governance layer is the answer to that question.
---
## Get Started
Chrome DevTools MCP is available on all Molecule AI deployments running Phase 30 or later.
- [MCP Server Setup Guide](/docs/guides/mcp-server-setup) — configure MCP tools in your workspace
- [Org API Keys: Audit Attribution Setup](/blog/org-scoped-api-keys) — set up org API keys with attribution
- [A2A Protocol Reference](/docs/api-protocol/a2a-protocol) — how agents delegate browser tasks to each other
<Callout variant="info">
Chrome DevTools MCP requires Chrome running with the remote debugging port enabled. CDP access is scoped per org API key — multiple agents can share Chrome sessions only if intentionally scoped that way via key design.
</Callout>

View File

@ -138,6 +138,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
{FILTERS.map((f) => (
<button
type="button"
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
@ -152,6 +153,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
))}
<div className="flex-1" />
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
@ -190,6 +192,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{cursor && (
<div className="mt-4 flex justify-center">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"

View File

@ -91,6 +91,7 @@ export function BatchActionBar() {
{/* Action buttons */}
<button
type="button"
disabled={busy}
onClick={() => setPending("restart")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
@ -100,6 +101,7 @@ export function BatchActionBar() {
</button>
<button
type="button"
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
@ -109,6 +111,7 @@ export function BatchActionBar() {
</button>
<button
type="button"
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
@ -121,6 +124,7 @@ export function BatchActionBar() {
{/* Deselect */}
<button
type="button"
disabled={busy}
onClick={clearSelection}
aria-label="Clear selection"

View File

@ -108,6 +108,7 @@ export function BundleDropZone() {
{/* Keyboard-accessible import button visible on focus or hover so
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Import bundle file"
aria-controls="bundle-file-input"

View File

@ -1,21 +1,18 @@
"use client";
import { useCallback, useRef, useMemo, useEffect, useState } from "react";
import { useCallback, useMemo } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
useReactFlow,
type OnNodeDrag,
type Node,
type Edge,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useCanvasStore } from "@/store/canvas";
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
import { WorkspaceNode } from "./WorkspaceNode";
import { SidePanel } from "./SidePanel";
@ -27,17 +24,19 @@ import { BundleDropZone } from "./BundleDropZone";
import { EmptyState } from "./EmptyState";
import { OnboardingWizard } from "./OnboardingWizard";
import { SearchDialog } from "./SearchDialog";
import { Toaster } from "./Toaster";
import { Toaster, showToast } from "./Toaster";
import { Toolbar } from "./Toolbar";
import { ConfirmDialog } from "./ConfirmDialog";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
// Phase 20 components
import { SettingsPanel, DeleteConfirmDialog } from "./settings";
// Phase 20.3 batch operations
import { BatchActionBar } from "./BatchActionBar";
import { ProvisioningTimeout } from "./ProvisioningTimeout";
import { DropTargetBadge } from "./canvas/DropTargetBadge";
import { useDragHandlers } from "./canvas/useDragHandlers";
import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts";
import { useCanvasViewport } from "./canvas/useCanvasViewport";
const nodeTypes = {
workspaceNode: WorkspaceNode,
};
@ -63,57 +62,33 @@ function CanvasInner() {
const edges = useCanvasStore((s) => s.edges);
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
const showA2AEdges = useCanvasStore((s) => s.showA2AEdges);
// Merge topology edges with A2A overlay edges via useMemo (no new object in selector)
const allEdges = useMemo(
() => (showA2AEdges ? [...edges, ...a2aEdges] : edges),
[edges, a2aEdges, showA2AEdges]
[edges, a2aEdges, showA2AEdges],
);
const onNodesChange = useCanvasStore((s) => s.onNodesChange);
const savePosition = useCanvasStore((s) => s.savePosition);
const selectNode = useCanvasStore((s) => s.selectNode);
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
const setDragOverNode = useCanvasStore((s) => s.setDragOverNode);
const nestNode = useCanvasStore((s) => s.nestNode);
const isDescendant = useCanvasStore((s) => s.isDescendant);
const dragStartParentRef = useRef<string | null>(null);
const { getIntersectingNodes } = useReactFlow();
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(_event, node) => {
dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId;
},
[]
);
// Drag / nest lifecycle — handlers, pending-nest state, confirm/cancel.
const {
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
pendingNest,
confirmNest,
cancelNest,
} = useDragHandlers();
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(_event, node) => {
// Only consider nodes within a proximity threshold as nest targets.
// Without this check, getIntersectingNodes returns any node whose bounding
// boxes overlap — which can be hundreds of pixels away on a sparse canvas,
// causing accidental nesting when the user drags a node across the board.
const thresholdPx = 100;
const threshold = thresholdPx * thresholdPx; // compare squared distances
let nearest: { id: string; dist: number } | null = null;
for (const candidate of getIntersectingNodes(node)) {
if (candidate.id === node.id || isDescendant(node.id, candidate.id)) continue;
const dx = candidate.position.x - node.position.x;
const dy = candidate.position.y - node.position.y;
const dist2 = dx * dx + dy * dy;
if (dist2 <= threshold && (!nearest || dist2 < nearest.dist)) {
nearest = { id: candidate.id, dist: dist2 };
}
}
setDragOverNode(nearest?.id ?? null);
},
[getIntersectingNodes, isDescendant, setDragOverNode]
);
// Window-level keyboard shortcuts (Esc, Enter, Shift+Enter, Cmd+]/[, Z).
useKeyboardShortcuts();
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport();
// Confirmation dialog state for structure changes
const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null);
// Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler (the portal-rendered Confirm button counted as
// "outside" and closed the menu, killing the dialog mid-click).
// outside-click handler.
const pendingDelete = useCanvasStore((s) => s.pendingDelete);
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const removeNode = useCanvasStore((s) => s.removeNode);
@ -129,48 +104,6 @@ function CanvasInner() {
}
}, [pendingDelete, setPendingDelete, removeNode]);
// Cascade guard: include child count in the warning message when the workspace
// has children, so the user understands the blast radius before clicking Delete All.
const cascadeMessage = pendingDelete?.hasChildren
? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.`
: null;
const onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
(_event, node) => {
const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState();
setDragOverNode(null);
const nodeName = (node.data as WorkspaceNodeData).name;
if (dragOverNodeId) {
const targetNode = allNodes.find((n) => n.id === dragOverNodeId);
const targetName = targetNode?.data.name || "Unknown";
setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName });
} else {
const currentParentId = (node.data as WorkspaceNodeData).parentId;
if (currentParentId) {
const parentNode = allNodes.find((n) => n.id === currentParentId);
const parentName = parentNode?.data.name || "Unknown";
setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName });
}
}
savePosition(node.id, node.position.x, node.position.y);
},
[savePosition, setDragOverNode]
);
const confirmNest = useCallback(() => {
if (pendingNest) {
nestNode(pendingNest.nodeId, pendingNest.targetId);
setPendingNest(null);
}
}, [pendingNest, nestNode]);
const cancelNest = useCallback(() => {
setPendingNest(null);
}, []);
const onPaneClick = useCallback(() => {
selectNode(null);
const state = useCanvasStore.getState();
@ -178,123 +111,14 @@ function CanvasInner() {
state.clearSelection();
}, [selectNode]);
// Team zoom-in: double-click a team node to zoom to its children
const { fitBounds, fitView } = useReactFlow();
// Pan to newly deployed workspace.
// Uses fitView({ nodes }) so the viewport adapts to any current zoom level
// instead of forcing zoom=1 (which was jarring when the user was zoomed out).
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
// Small delay so ReactFlow has time to measure the newly rendered node
clearTimeout(panTimerRef.current);
panTimerRef.current = setTimeout(() => {
fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
}, 100);
};
window.addEventListener("molecule:pan-to-node", handler);
return () => {
window.removeEventListener("molecule:pan-to-node", handler);
clearTimeout(panTimerRef.current);
};
}, [fitView]);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent).detail;
const state = useCanvasStore.getState();
const children = state.nodes.filter((n) => n.data.parentId === nodeId);
if (children.length === 0) return;
const parent = state.nodes.find((n) => n.id === nodeId);
const allNodes = parent ? [parent, ...children] : children;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of allNodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + 260);
maxY = Math.max(maxY, n.position.y + 120);
}
fitBounds(
{ x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 },
{ padding: 0.2, duration: 500 }
);
};
window.addEventListener("molecule:zoom-to-team", handler);
return () => window.removeEventListener("molecule:zoom-to-team", handler);
}, [fitBounds]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
} else if (state.selectedNodeIds.size > 0) {
state.clearSelection();
} else if (state.selectedNodeId) {
state.selectNode(null);
}
}
// Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1)
if (e.key === "z" || e.key === "Z") {
const tag = (e.target as HTMLElement).tagName;
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable
)
return;
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId);
if (hasChildren) {
window.dispatchEvent(
new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } })
);
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const saveViewport = useCanvasStore((s) => s.saveViewport);
const viewport = useCanvasStore((s) => s.viewport);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Cleanup debounced save timer on unmount
useEffect(() => {
return () => clearTimeout(saveTimerRef.current);
}, []);
const onMoveEnd = useCallback(
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
// Debounce viewport saves to avoid spamming the API
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveViewport(vp.x, vp.y, vp.zoom);
}, 1000);
},
[saveViewport]
);
const defaultViewport = useMemo(
() => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }),
// Only use the initial viewport — don't re-render on every save
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
[],
);
// Determine which workspace ID to use for global settings.
// Fall back to "global" when no specific node is selected.
const settingsWorkspaceId = selectedNodeId ?? "global";
return (
@ -306,112 +130,118 @@ function CanvasInner() {
Skip to canvas
</a>
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
<ReactFlow
colorMode="dark"
nodes={nodes}
edges={allEdges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onPaneClick={onPaneClick}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={defaultViewport}
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
aria-label="Molecule AI workspace canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#27272a"
<ReactFlow
colorMode="dark"
nodes={nodes}
edges={allEdges}
onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onPaneClick={onPaneClick}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={defaultViewport}
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
aria-label="Molecule AI workspace canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#27272a"
/>
<Controls
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
showInteractive={false}
/>
<MiniMap
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(node) => {
// Parents show as a filled region — hierarchy visible at
// a glance in the minimap without needing to zoom.
const hasChildren = nodes.some((n) => n.parentId === node.id);
if (hasChildren) return "#3b82f6";
const status = (node.data as Record<string, unknown>)?.status;
switch (status) {
case "online":
return "#34d399";
case "offline":
return "#52525b";
case "degraded":
return "#fbbf24";
case "failed":
return "#f87171";
case "provisioning":
return "#38bdf8";
default:
return "#3f3f46";
}
}}
nodeStrokeColor={(node) => {
const hasChildren = nodes.some((n) => n.parentId === node.id);
return hasChildren ? "#60a5fa" : "transparent";
}}
nodeStrokeWidth={2}
nodeBorderRadius={4}
/>
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}
<A2ATopologyOverlay />
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
<BundleDropZone />
<TemplatePalette />
<SidePanel />
<ContextMenu />
<SearchDialog />
<Toaster />
<ProvisioningTimeout />
{!selectedNodeId && <CreateWorkspaceButton />}
<BatchActionBar />
<ConfirmDialog
open={!!pendingNest}
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
message={
pendingNest?.targetId
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
}
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
onConfirm={confirmNest}
onCancel={cancelNest}
/>
<Controls
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
showInteractive={false}
<ConfirmDialog
open={!!pendingDelete}
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
message={pendingDelete?.hasChildren
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<MiniMap
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(node) => {
const status = (node.data as Record<string, unknown>)?.status;
switch (status) {
case "online":
return "#34d399";
case "offline":
return "#52525b";
case "degraded":
return "#fbbf24";
case "failed":
return "#f87171";
case "provisioning":
return "#38bdf8";
default:
return "#3f3f46";
}
}}
nodeStrokeWidth={0}
nodeBorderRadius={4}
/>
</ReactFlow>
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.data.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}
<A2ATopologyOverlay />
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
<BundleDropZone />
<TemplatePalette />
<SidePanel />
<ContextMenu />
<SearchDialog />
<Toaster />
<ProvisioningTimeout />
{!selectedNodeId && <CreateWorkspaceButton />}
<BatchActionBar />
{/* Confirmation dialog for structure changes */}
<ConfirmDialog
open={!!pendingNest}
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
message={
pendingNest?.targetId
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
}
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
onConfirm={confirmNest}
onCancel={cancelNest}
/>
{/* Confirmation dialog for workspace delete — driven by store */}
<ConfirmDialog
open={!!pendingDelete}
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
message={pendingDelete?.hasChildren
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
{/* Settings Panel — global secrets management drawer */}
<SettingsPanel workspaceId={settingsWorkspaceId} />
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
<SettingsPanel workspaceId={settingsWorkspaceId} />
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
</main>
</>
);

View File

@ -99,6 +99,7 @@ export function CommunicationOverlay() {
if (!visible || comms.length === 0) {
return (
<button
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
@ -115,6 +116,7 @@ export function CommunicationOverlay() {
<span aria-hidden="true"> </span>Communications ({comms.length})
</div>
<button
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-zinc-500 hover:text-zinc-300 text-xs"

View File

@ -121,6 +121,7 @@ export function ConfirmDialog({
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
{!singleButton && (
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
@ -128,6 +129,7 @@ export function ConfirmDialog({
</button>
)}
<button
type="button"
onClick={onConfirm}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
>

View File

@ -109,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
</div>
<button
type="button"
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
@ -146,6 +147,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
{output && (
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(output);
@ -159,6 +161,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
</button>
)}
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>

View File

@ -202,15 +202,22 @@ export function ContextMenu() {
closeContextMenu();
}, [contextMenu, closeContextMenu]);
const setCollapsed = useCanvasStore((s) => s.setCollapsed);
const handleCollapse = useCallback(async () => {
if (!contextMenu) return;
const nodeId = contextMenu.nodeId;
const wasCollapsed = !!contextMenu.nodeData.collapsed;
// Optimistic local flip so the card shrinks/expands immediately.
// Descendants' hidden flags are toggled atomically by the store.
setCollapsed(nodeId, !wasCollapsed);
try {
await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {});
await api.patch(`/workspaces/${nodeId}`, { collapsed: !wasCollapsed });
} catch (e) {
setCollapsed(nodeId, wasCollapsed);
showToast("Collapse failed", "error");
}
closeContextMenu();
}, [contextMenu, closeContextMenu]);
}, [contextMenu, setCollapsed, closeContextMenu]);
const handleRemoveFromTeam = useCallback(async () => {
if (!contextMenu) return;
@ -223,6 +230,13 @@ export function ContextMenu() {
closeContextMenu();
}, [contextMenu, nestNode, closeContextMenu]);
const arrangeChildren = useCanvasStore((s) => s.arrangeChildren);
const handleArrangeChildren = useCallback(() => {
if (!contextMenu) return;
arrangeChildren(contextMenu.nodeId);
closeContextMenu();
}, [contextMenu, arrangeChildren, closeContextMenu]);
const handleZoomToTeam = useCallback(() => {
if (!contextMenu) return;
window.dispatchEvent(
@ -250,7 +264,12 @@ export function ContextMenu() {
: []),
...(hasChildren
? [
{ label: "Collapse Team", icon: "◁", action: handleCollapse },
{ label: "Arrange Children", icon: "▦", action: handleArrangeChildren },
{
label: contextMenu.nodeData.collapsed ? "Expand Team" : "Collapse Team",
icon: contextMenu.nodeData.collapsed ? "▽" : "◁",
action: handleCollapse,
},
{ label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam },
]
: [{ label: "Expand to Team", icon: "▷", action: handleExpand }]),
@ -289,6 +308,7 @@ export function ContextMenu() {
}
return (
<button
type="button"
key={i}
role="menuitem"
onClick={item.action}

View File

@ -112,6 +112,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
</div>
<Dialog.Close asChild>
<button
type="button"
aria-label="Close conversation trace"
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
>
@ -283,6 +284,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
>
Close

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { isSaaSTenant } from "@/lib/tenant";
const STORAGE_KEY = "molecule_cookie_consent";
@ -74,7 +75,18 @@ export function CookieConsent() {
// Read persisted decision on mount. useState's initialState can't run
// on first render because localStorage is SSR-unsafe — defer to
// useEffect so the initial HTML is identical to the server snapshot.
//
// The banner is SaaS-only: it carries a link to the hosted
// privacy policy (moleculesai.app/legal/privacy) and presumes
// GDPR/ePrivacy obligations that only apply to the hosted offering.
// Self-hosted / local-dev / Vercel-preview hosts get no banner —
// matches the `isSaaSTenant()` convention used by AuthGate and
// the tier picker.
useEffect(() => {
if (!isSaaSTenant()) {
setVisible(false);
return;
}
setVisible(getStoredConsent() === null);
}, []);

View File

@ -89,7 +89,13 @@ export function CreateWorkspaceButton() {
],
[isSaaS],
);
const defaultTier = isSaaS ? 4 : 1;
// T3 ("Privileged") is the self-hosted default — gives agents the
// read_write workspace mount + Docker daemon access most templates
// expect to do real work. T1 sandboxed and T2 standard are kept as
// explicit opt-ins for low-trust agents. SaaS still defaults to T4
// because every SaaS workspace gets its own EC2 (sibling VMs, no
// shared blast radius — see isSaaSTenant() / tier picker hide logic).
const defaultTier = isSaaS ? 4 : 3;
const [tier, setTier] = useState(defaultTier);
// Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)
@ -205,7 +211,7 @@ export function CreateWorkspaceButton() {
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
<svg
width="14"
height="14"
@ -278,6 +284,7 @@ export function CreateWorkspaceButton() {
</div>
{TIERS.map((t, idx) => (
<button
type="button"
key={t.value}
ref={(el) => { radioRefs.current[idx] = el; }}
role="radio"
@ -426,11 +433,12 @@ export function CreateWorkspaceButton() {
<div className="flex justify-end gap-2.5 mt-6">
<Dialog.Close asChild>
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
<button type="button" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
Cancel
</button>
</Dialog.Close>
<button
type="button"
onClick={handleCreate}
disabled={creating}
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"

View File

@ -143,12 +143,14 @@ export function DeleteCascadeConfirmDialog({
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={!checked}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors

View File

@ -110,6 +110,7 @@ export function EmptyState() {
const tierColor = TIER_CONFIG[t.tier]?.border || TIER_CONFIG[1].border;
return (
<button
type="button"
key={t.id}
onClick={() => deploy(t)}
disabled={!!deploying}
@ -140,6 +141,7 @@ export function EmptyState() {
{/* Create blank */}
<button
type="button"
onClick={createBlank}
disabled={!!deploying}
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"

View File

@ -63,6 +63,7 @@ export class ErrorBoundary extends React.Component<
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
@ -80,6 +81,7 @@ export class ErrorBoundary extends React.Component<
</p>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
>

View File

@ -1,12 +1,18 @@
"use client";
import { STATUS_CONFIG } from "@/lib/design-tokens";
import { useCanvasStore } from "@/store/canvas";
const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const;
export function Legend() {
// TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the
// default bottom-6 left-4 position of this legend would sit under it.
// Shift past the 280 px palette + a 16 px gap when the palette is open.
const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen);
const leftClass = paletteOpen ? "left-[296px]" : "left-4";
return (
<div className="fixed bottom-6 left-4 z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px]">
<div className={`fixed bottom-6 ${leftClass} z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">Legend</div>
{/* Status */}

View File

@ -160,6 +160,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<div className="flex items-center gap-1">
{SCOPES.map((scope) => (
<button
type="button"
key={scope}
onClick={() => setActiveScope(scope)}
aria-pressed={activeScope === scope}
@ -201,6 +202,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setDebouncedQuery("");
@ -240,6 +242,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
: `${entries.length} memories`}
</span>
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
aria-label="Refresh memories"
@ -273,6 +276,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Try a different query or{" "}
<button
type="button"
onClick={() => {
setSearchQuery("");
setDebouncedQuery("");
@ -339,6 +343,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
@ -409,6 +414,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
Created: {new Date(entry.created_at).toLocaleString()}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();

View File

@ -1,33 +1,374 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { api } from "@/lib/api";
import { getKeyLabel } from "@/lib/deploy-preflight";
import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight";
interface Props {
open: boolean;
/** Flat list of every candidate env var. Used as the fallback input
* set when `providers` is empty (or length 1). */
missingKeys: string[];
/** Grouped provider options derived from the template's models[] /
* required_env. When length 2 the modal shows a radio picker. */
providers?: ProviderChoice[];
/** Runtime slug used only for the "The <runtime> runtime "
* headline; behavior is driven by providers/missingKeys. */
runtime: string;
/** Called when user adds all keys and wants to proceed with deploy. */
/** Called when all required keys for the chosen provider are saved. */
onKeysAdded: () => void;
/** Called when user cancels the deploy. */
/** Called when the user cancels the deploy. */
onCancel: () => void;
/** Called when user wants to open the Settings Panel (Config tab → Secrets). */
/** Optional — open the Settings Panel (Config tab → Secrets). */
onOpenSettings?: () => void;
/** Optional workspace ID — if provided, secrets are saved at workspace scope. */
/** If provided, secrets save at workspace scope instead of global. */
workspaceId?: string;
}
interface KeyEntry {
key: string;
label: string;
value: string;
saved: boolean;
saving: boolean;
error: string | null;
}
/**
* MissingKeysModal
* ----------------
* Dispatches between two modes based on what the template declares:
*
* 1. PROVIDER PICKER when the preflight returned 2 `providers` (e.g.
* a Hermes template whose models[].required_env enumerate OpenRouter,
* Anthropic, Nous-native, etc.). Radio list of options, saving the
* chosen option's env vars satisfies the deploy.
*
* 2. ALL-KEYS every entry in `missingKeys` rendered as its own input,
* all must save before Deploy. Used when the template has a single
* provider option or no declared alternatives.
*
* The modal never hardcodes per-runtime provider lists; the upstream
* preflight derives that from the template config.yaml.
*/
export function MissingKeysModal({
open,
missingKeys,
providers,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
}: Props) {
const pickerProviders = providers ?? [];
const pickerMode = pickerProviders.length > 1;
if (pickerMode) {
return (
<ProviderPickerModal
open={open}
providers={pickerProviders}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
/>
);
}
// Prefer the (single) provider's envVars over the raw missingKeys when
// we have one — the provider list is already de-duped and ordered.
const keys =
pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys;
return (
<AllKeysModal
open={open}
missingKeys={keys}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
/>
);
}
// -----------------------------------------------------------------------------
// Provider-picker mode — choose one option, save its env var(s), deploy.
// -----------------------------------------------------------------------------
function ProviderPickerModal({
open,
providers,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
}: {
open: boolean;
providers: ProviderChoice[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
}) {
const [selectedId, setSelectedId] = useState(providers[0].id);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(null);
const selected = useMemo(
() => providers.find((p) => p.id === selectedId) ?? providers[0],
[providers, selectedId],
);
useEffect(() => {
if (!open) return;
setSelectedId(providers[0].id);
}, [open, providers]);
useEffect(() => {
if (!open) return;
setEntries(
selected.envVars.map((key) => ({
key,
value: "",
saved: false,
saving: false,
error: null,
})),
);
}, [open, selected]);
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, selectedId]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onCancel]);
const updateEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setEntries((prev) =>
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
);
},
[],
);
const handleSaveKey = useCallback(
async (index: number) => {
const entry = entries[index];
if (!entry.value.trim()) return;
updateEntry(index, { saving: true, error: null });
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
value: entry.value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: entry.key,
value: entry.value.trim(),
});
}
updateEntry(index, { saved: true, saving: false });
} catch (e) {
updateEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[entries, updateEntry, workspaceId],
);
if (!open) return null;
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
Missing API Keys
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
runtime supports multiple providers. Pick one and paste its API key.
</p>
</div>
<div className="px-5 py-4 space-y-3">
<fieldset className="space-y-1.5">
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
Provider
</legend>
{providers.map((p) => (
<label
key={p.id}
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
selectedId === p.id
? "bg-blue-600/15 border-blue-500/50"
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
}`}
>
<input
type="radio"
name="provider"
value={p.id}
checked={selectedId === p.id}
onChange={() => setSelectedId(p.id)}
className="mt-0.5 accent-blue-500"
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
<div className="text-[10px] font-mono text-zinc-500">
{p.envVars.join(", ")}
</div>
{p.note && (
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
{p.note}
</div>
)}
</div>
</label>
))}
</fieldset>
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
ref={index === 0 ? firstInputRef : undefined}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveKey(index);
}
}}
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
)}
</div>
))}
</div>
</div>
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
Open Settings Panel
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Cancel Deploy
</button>
<button
onClick={onKeysAdded}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
>
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
</div>
</div>
</div>
</div>
);
}
// -----------------------------------------------------------------------------
// All-keys mode — every missingKey rendered as its own input, all required.
// -----------------------------------------------------------------------------
function AllKeysModal({
open,
missingKeys,
runtime,
@ -35,18 +376,23 @@ export function MissingKeysModal({
onCancel,
onOpenSettings,
workspaceId,
}: Props) {
}: {
open: boolean;
missingKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
// Initialize entries when modal opens or missingKeys change
useEffect(() => {
if (!open) return;
setEntries(
missingKeys.map((key) => ({
key,
label: getKeyLabel(key),
value: "",
saved: false,
saving: false,
@ -56,14 +402,6 @@ export function MissingKeysModal({
setGlobalError(null);
}, [open, missingKeys]);
// Focus first input when modal opens
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
firstInputRef.current?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
@ -90,7 +428,6 @@ export function MissingKeysModal({
updateEntry(index, { saving: true, error: null });
try {
// Save to global scope by default (available to all workspaces)
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
@ -127,39 +464,45 @@ export function MissingKeysModal({
onKeysAdded();
}, [entries, onKeysAdded]);
// Focus trap: auto-focus first input when modal opens
useEffect(() => {
if (!open) return;
const timer = requestAnimationFrame(() => {
document.getElementById("missing-keys-title")?.focus();
});
return () => cancelAnimationFrame(timer);
}, [open]);
if (!open) return null;
const allSaved = entries.every((e) => e.saved);
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-hidden="true"
onClick={onCancel}
/>
{/* Dialog */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden"
>
{/* Header */}
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M6 1L11 10H1L6 1Z"
stroke="#fbbf24"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
@ -169,12 +512,11 @@ export function MissingKeysModal({
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
requires the following keys to be configured before deploying.
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</p>
</div>
{/* Body — key list */}
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
{entries.map((entry, index) => (
<div
@ -184,15 +526,13 @@ export function MissingKeysModal({
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{entry.label}
</div>
<div className="text-[9px] font-mono text-zinc-500">
{entry.key}
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
@ -207,7 +547,7 @@ export function MissingKeysModal({
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
ref={index === 0 ? firstInputRef : undefined}
autoFocus={index === 0}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveKey(index);
@ -216,6 +556,7 @@ export function MissingKeysModal({
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
@ -225,9 +566,7 @@ export function MissingKeysModal({
</div>
)}
{entry.error && (
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>}
</div>
))}
@ -238,11 +577,11 @@ export function MissingKeysModal({
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
@ -252,12 +591,14 @@ export function MissingKeysModal({
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Cancel Deploy
</button>
<button
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"

View File

@ -159,6 +159,7 @@ export function OnboardingWizard() {
Step {currentStepIdx + 1} of {STEPS.length}
</span>
<button
type="button"
onClick={dismiss}
aria-label="Skip onboarding guide"
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
@ -178,6 +179,7 @@ export function OnboardingWizard() {
{/* Action button */}
<div className="flex gap-2">
<button
type="button"
onClick={handleAction}
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
>
@ -191,6 +193,7 @@ export function OnboardingWizard() {
</button>
{step !== "done" && (
<button
type="button"
onClick={() => {
const next = STEPS[currentStepIdx + 1];
if (next) setStep(next.id);

View File

@ -6,9 +6,27 @@ import { api } from "@/lib/api";
import { showToast } from "./Toaster";
import { ConsoleModal } from "./ConsoleModal";
/** Default provisioning timeout in milliseconds (2 minutes). */
/** Base provisioning timeout in milliseconds (2 minutes). Used as the
* floor; the effective threshold scales with the number of workspaces
* concurrently provisioning (see effectiveTimeoutMs below). */
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;
/** The server provisions up to `PROVISION_CONCURRENCY` containers at
* once and paces the rest in a queue (`workspaceCreatePacingMs` =
* 2s). Mirrors the Go constants if those change, bump these. */
const PROVISION_CONCURRENCY = 3;
const PER_QUEUE_SLOT_EXTRA_MS = 45_000; // ~45s head-room per queued workspace
/** Scale the base timeout by how many workspaces are provisioning at
* once. A 30-workspace org import has tail items that legitimately
* wait minutes before Docker even starts on them flagging each as
* "stuck" after 2m creates a wall of 27 yellow banners that buries
* the canvas. */
function effectiveTimeoutMs(base: number, concurrentCount: number): number {
const overflow = Math.max(0, concurrentCount - PROVISION_CONCURRENCY);
return base + overflow * PER_QUEUE_SLOT_EXTRA_MS;
}
interface TimeoutEntry {
workspaceId: string;
workspaceName: string;
@ -33,6 +51,10 @@ export function ProvisioningTimeout({
const [retrying, setRetrying] = useState<Set<string>>(new Set());
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const trackingRef = useRef<Map<string, number>>(new Map());
// Workspaces the user explicitly dismissed — don't re-show their
// banner even if they stay in provisioning. Cleared when the
// workspace leaves provisioning (status changes).
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
// Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render
// (filter+map creates new array reference on every store update)
@ -71,17 +93,34 @@ export function ProvisioningTimeout({
}
}
// Also remove from timedOut list if no longer provisioning
// Also remove from timedOut list if no longer provisioning, and
// clear `dismissed` entries for workspaces that finished so a
// re-provision (e.g. retry) can surface a fresh banner.
setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId)));
setDismissed((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of prev) {
if (!activeIds.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : prev;
});
// Interval to check for timeouts
const interval = setInterval(() => {
const now = Date.now();
const newTimedOut: TimeoutEntry[] = [];
const effective = effectiveTimeoutMs(
timeoutMs,
parsedProvisioningNodes.length,
);
for (const node of parsedProvisioningNodes) {
const startedAt = tracking.get(node.id);
if (startedAt && now - startedAt >= timeoutMs) {
if (startedAt && now - startedAt >= effective) {
newTimedOut.push({
workspaceId: node.id,
workspaceName: node.name,
@ -104,6 +143,11 @@ export function ProvisioningTimeout({
return () => clearInterval(interval);
}, [parsedProvisioningNodes, timeoutMs]);
const handleDismiss = useCallback((workspaceId: string) => {
setDismissed((prev) => new Set(prev).add(workspaceId));
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
}, []);
const RETRY_COOLDOWN_MS = 5_000;
const [retryCooldown, setRetryCooldown] = useState<Set<string>>(new Set());
@ -180,11 +224,16 @@ export function ProvisioningTimeout({
setConsoleFor(workspaceId);
}, []);
if (timedOut.length === 0) return null;
const visibleTimedOut = useMemo(
() => timedOut.filter((e) => !dismissed.has(e.workspaceId)),
[timedOut, dismissed],
);
if (visibleTimedOut.length === 0) return null;
return (
<div role="alert" aria-live="assertive" className="fixed top-14 left-1/2 -translate-x-1/2 z-40 flex flex-col gap-2 max-w-[480px] w-full px-4">
{timedOut.map((entry) => {
{visibleTimedOut.map((entry) => {
const elapsed = Math.round((Date.now() - entry.startedAt) / 1000);
const isRetrying = retrying.has(entry.workspaceId);
const isCancelling = cancelling.has(entry.workspaceId);
@ -210,8 +259,20 @@ export function ProvisioningTimeout({
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-amber-200 mb-0.5">
Provisioning Timeout
<div className="flex items-center justify-between mb-0.5 gap-2">
<div className="text-[12px] font-semibold text-amber-200">
Provisioning Timeout
</div>
<button
onClick={() => handleDismiss(entry.workspaceId)}
aria-label="Dismiss provisioning timeout warning"
title="Dismiss — keep this workspace running without the warning"
className="shrink-0 text-amber-400/60 hover:text-amber-200 transition-colors -mr-1"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="text-[11px] text-amber-300/80 leading-relaxed">
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
@ -223,6 +284,7 @@ export function ProvisioningTimeout({
{/* Action buttons */}
<div className="flex items-center gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
@ -230,6 +292,7 @@ export function ProvisioningTimeout({
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
<button
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
@ -237,6 +300,7 @@ export function ProvisioningTimeout({
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
>
@ -262,12 +326,14 @@ export function ProvisioningTimeout({
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>

View File

@ -132,6 +132,7 @@ export function SearchDialog() {
) : (
filtered.map((node, index) => (
<button
type="button"
key={node.id}
id={`search-result-${node.id}`}
role="option"

View File

@ -46,11 +46,15 @@ export function SidePanel() {
const panelTab = useCanvasStore((s) => s.panelTab);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const selectNode = useCanvasStore((s) => s.selectNode);
const setSidePanelWidth = useCanvasStore((s) => s.setSidePanelWidth);
const node = useCanvasStore((s) =>
s.nodes.find((n) => n.id === s.selectedNodeId)
);
// Resizable panel width — persisted across node selections via localStorage
// Resizable panel width — persisted across node selections via localStorage.
// Also published to the canvas store on every change so the centered
// Toolbar can re-centre itself on the remaining canvas area (avoids the
// Audit / Search / Settings buttons hiding under the panel).
const [width, setWidth] = useState<number>(() => {
if (typeof window === "undefined") return SIDEPANEL_DEFAULT_WIDTH;
const saved = localStorage.getItem(SIDEPANEL_WIDTH_KEY);
@ -59,6 +63,9 @@ export function SidePanel() {
? parsed
: SIDEPANEL_DEFAULT_WIDTH;
});
useEffect(() => {
setSidePanelWidth(width);
}, [width, setSidePanelWidth]);
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
const dragging = useRef(false);
const startX = useRef(0);
@ -171,6 +178,7 @@ export function SidePanel() {
</div>
</div>
<button
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
@ -214,6 +222,7 @@ export function SidePanel() {
>
{TABS.map((tab) => (
<button
type="button"
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
@ -239,6 +248,7 @@ export function SidePanel() {
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
<span className="text-[10px] text-sky-300/90">Config changed restart to apply</span>
<button
type="button"
onClick={() => {
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
}}

View File

@ -2,10 +2,13 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
import { showToast } from "./Toaster";
import { TIER_CONFIG } from "@/lib/design-tokens";
interface Template {
@ -13,7 +16,11 @@ interface Template {
name: string;
description: string;
tier: number;
runtime?: string;
model: string;
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config.required_env. */
required_env?: string[];
skills: string[];
skill_count: number;
}
@ -35,10 +42,41 @@ export async function fetchOrgTemplates(): Promise<OrgTemplate[]> {
}
}
/** Import an org template by directory name. Throws on platform error so the
* caller can surface the message in its error state. */
export async function importOrgTemplate(dir: string): Promise<void> {
await api.post("/org/import", { dir });
/** Server response from POST /org/import. The handler returns 207
* (StatusMultiStatus) with a populated `error` field when only some of
* the workspaces in the tree could be created the HTTP status alone
* isn't enough to detect a partial failure. */
interface OrgImportResponse {
org: string;
workspaces: Array<{ id: string; name: string }>;
count: number;
error?: string;
}
/** Import an org template by directory name. Throws on platform error
* so the caller can surface the message in its error state. Also throws
* on 2xx-with-error-body (StatusMultiStatus) without this check a
* partial failure (e.g. first workspace INSERT fails, 0 created)
* appears as a green success toast and the user sees no canvas update.
*
* Uses a long timeout because createWorkspaceTree paces sibling DB
* inserts by `workspaceCreatePacingMs` (2s) to avoid overwhelming
* Docker a 15-workspace tree sleeps ~28s in the handler alone,
* which blows past the default 15s and makes the client report a
* spurious "signal timed out" error even though the server finished
* successfully. 2min covers trees up to ~60 workspaces. */
const ORG_IMPORT_TIMEOUT_MS = 120_000;
export async function importOrgTemplate(dir: string): Promise<OrgImportResponse> {
const resp = await api.post<OrgImportResponse>(
"/org/import",
{ dir },
{ timeoutMs: ORG_IMPORT_TIMEOUT_MS },
);
if (resp && resp.error) {
throw new Error(`${resp.error} (created ${resp.count ?? 0} workspaces)`);
}
return resp;
}
/**
@ -53,6 +91,13 @@ export function OrgTemplatesSection() {
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Collapsed by default — org templates are multi-workspace imports
// that most new users don't reach for first. Keeping them
// expand-on-demand frees ~400 px of vertical space for the
// individual workspace templates above, which is the primary
// deploy path. The count in the header still makes discovery
// obvious: "Org Templates (4) ▸".
const [expanded, setExpanded] = useState(false);
const loadOrgs = useCallback(async () => {
setLoading(true);
@ -69,8 +114,22 @@ export function OrgTemplatesSection() {
setError(null);
try {
await importOrgTemplate(org.dir);
// Refresh canvas inline — the WebSocket may be offline, in which case
// WORKSPACE_PROVISIONING broadcasts never arrive and the user sees
// no change from clicking "Import org". A direct fetch guarantees
// the new workspaces land on canvas regardless of WS state.
try {
const workspaces = await api.get<WorkspaceData[]>("/workspaces");
useCanvasStore.getState().hydrate(workspaces);
} catch {
// Rehydrate failure is non-fatal; WS (if alive) or the next
// health-check cycle will eventually pick the new workspaces up.
}
showToast(`Imported "${org.name || org.dir}" (${org.workspaces} workspaces)`, "success");
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
const msg = e instanceof Error ? e.message : "Import failed";
setError(msg);
showToast(`Import failed: ${msg}`, "error");
} finally {
setImporting(null);
}
@ -79,10 +138,28 @@ export function OrgTemplatesSection() {
return (
<div className="space-y-2" data-testid="org-templates-section">
<div className="flex items-center justify-between">
<h3 className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold">
Org Templates
</h3>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-300 font-semibold transition-colors"
>
<span
aria-hidden="true"
className={`inline-block text-[8px] transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
>
</span>
Org Templates
{orgs.length > 0 && (
<span className="text-zinc-600 normal-case tracking-normal">
({orgs.length})
</span>
)}
</button>
<button
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-zinc-500 hover:text-zinc-300"
@ -91,6 +168,8 @@ export function OrgTemplatesSection() {
</button>
</div>
{expanded && (
<div id="org-templates-body" className="space-y-2">
{loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-zinc-500">
<Spinner size="sm" />
@ -131,6 +210,7 @@ export function OrgTemplatesSection() {
</p>
)}
<button
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
@ -140,6 +220,8 @@ export function OrgTemplatesSection() {
</div>
);
})}
</div>
)}
</div>
);
}
@ -204,6 +286,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
@ -226,6 +309,14 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
export function TemplatePalette() {
const [open, setOpen] = useState(false);
// Publish palette-open state to the canvas store so Legend (and any
// future floating left-bottom UI) can shift right to avoid being
// hidden behind the 280 px palette drawer.
const setTemplatePaletteOpen = useCanvasStore((s) => s.setTemplatePaletteOpen);
useEffect(() => {
setTemplatePaletteOpen(open);
}, [open, setTemplatePaletteOpen]);
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState<string | null>(null);
@ -292,8 +383,15 @@ export function TemplatePalette() {
setCreating(template.id);
setError(null);
const runtime = resolveRuntime(template.id);
const preflight = await checkDeploySecrets(runtime);
// Prefer the runtime the Go /templates endpoint returned verbatim —
// resolveRuntime() is a legacy id→runtime fallback for installs whose
// template summary predates the `runtime` field.
const runtime = template.runtime ?? resolveRuntime(template.id);
const preflight = await checkDeploySecrets({
runtime,
models: template.models,
required_env: template.required_env,
});
if (!preflight.ok) {
// Missing keys — show the modal instead of deploying
@ -310,6 +408,7 @@ export function TemplatePalette() {
<>
{/* Toggle button */}
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
@ -331,6 +430,7 @@ export function TemplatePalette() {
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
onKeysAdded={() => {
if (missingKeysInfo) {
@ -351,6 +451,11 @@ export function TemplatePalette() {
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{/* Org templates live INSIDE the scroll container so an
* expanded list (15+ entries) is reachable instead of
* overflowing the fixed footer below. */}
<OrgTemplatesSection />
{loading && (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
<Spinner />
@ -376,6 +481,7 @@ export function TemplatePalette() {
return (
<button
type="button"
key={t.id}
onClick={() => handleDeploy(t)}
disabled={isDeploying}
@ -418,9 +524,9 @@ export function TemplatePalette() {
</div>
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
<OrgTemplatesSection />
<ImportAgentButton onImported={loadTemplates} />
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
>

View File

@ -102,6 +102,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={accept}
disabled={submitting}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"

View File

@ -63,6 +63,7 @@ export function Toaster() {
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
@ -90,6 +91,7 @@ export function Toaster() {
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"

View File

@ -16,6 +16,17 @@ export function Toolbar() {
const setShowA2AEdges = useCanvasStore((s) => s.setShowA2AEdges);
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const sidePanelWidth = useCanvasStore((s) => s.sidePanelWidth);
// Toolbar is fixed + centred on the viewport. When a workspace is
// selected the SidePanel (z-50, fixed right-0) opens and covers the
// right edge of the viewport — without this adjustment, the right
// half of the Toolbar (Audit / Search / Help / Settings) hides
// behind the panel. Shifting the toolbar LEFT by half the panel
// width re-centres it on the remaining canvas area.
const toolbarOffsetStyle = selectedNodeId
? { marginLeft: `-${sidePanelWidth / 2}px` }
: undefined;
const [stopping, setStopping] = useState(false);
const [restartingAll, setRestartingAll] = useState(false);
@ -116,14 +127,21 @@ export function Toolbar() {
}, []);
return (
<div className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20">
<div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
style={toolbarOffsetStyle}
>
{/* Logo / Title */}
<div className="flex items-center gap-2 pr-3 border-r border-zinc-800/60">
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
<span className="text-[11px] font-semibold text-zinc-300 tracking-wide">Molecule AI</span>
</div>
{/* Status counts */}
{/* Status pills + workspace total in one segment previously two
separate border-delimited cells; merged to drop a redundant
divider and keep the count compact. `whitespace-nowrap` prevents
"+ N sub" from wrapping onto a second line when the toolbar
gets tight. */}
<div className="flex items-center gap-2.5">
<StatusPill color={statusDotClass("online")} count={counts.online} label="online" />
{counts.offline > 0 && (
@ -135,11 +153,8 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
</div>
{/* Total */}
<div className="pl-3 border-l border-zinc-800/60">
<span className="text-[10px] text-zinc-500">
<span className="text-zinc-700" aria-hidden="true">·</span>
<span className="text-[10px] text-zinc-500 whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-zinc-600"> + {counts.children} sub</span>}
</span>
@ -153,6 +168,7 @@ export function Toolbar() {
{/* Stop All — visible when agents have active tasks */}
{counts.activeTasks > 0 && (
<button
type="button"
onClick={stopAll}
disabled={stopping}
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
@ -171,6 +187,7 @@ export function Toolbar() {
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
{needsRestartNodes.length > 0 && (
<button
type="button"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartingAll}
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
@ -186,13 +203,19 @@ export function Toolbar() {
</button>
)}
{/* Secondary tools below are icon-only (Figma/Linear pattern) text
label is exposed via title + aria-label for hover/screen-reader
users. The primary Stop All / Restart Pending buttons above keep
their text because they are urgent + conditional. */}
{/* A2A topology overlay toggle */}
<button
type="button"
onClick={() => setShowA2AEdges(!showA2AEdges)}
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center gap-1.5 px-2.5 py-1 border rounded-lg transition-colors ${
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
@ -200,8 +223,8 @@ export function Toolbar() {
>
{/* Mesh / network icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
@ -217,11 +240,11 @@ export function Toolbar() {
strokeLinecap="round"
/>
</svg>
<span className="text-[10px] font-medium">A2A</span>
</button>
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
<button
type="button"
onClick={() => {
if (selectedNodeId) {
setPanelTab("audit");
@ -230,13 +253,13 @@ export function Toolbar() {
}
}}
aria-label="Open audit trail for selected workspace"
title="View audit ledger for the selected workspace"
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
title="Audit — view ledger for the selected workspace"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
{/* Scroll / ledger icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
@ -245,35 +268,36 @@ export function Toolbar() {
<rect x="3" y="2" width="10" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M6 5.5h4M6 8h4M6 10.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
<span className="text-[10px] font-medium">Audit</span>
</button>
{/* Search shortcut */}
<button
type="button"
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
aria-label="Search workspaces"
title="Search (⌘K)"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="text-[10px] text-zinc-500">Search</span>
<kbd className="text-[8px] text-zinc-600 bg-zinc-900/60 px-1 py-0.5 rounded border border-zinc-700/30">K</kbd>
</button>
{/* Quick help */}
<div ref={helpRef} className="relative">
<button
type="button"
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
aria-expanded={helpOpen}
aria-label="Open quick help"
title="Help — shortcuts & quick start"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12v.5M6.5 6.3A1.9 1.9 0 1 1 9 8.1c-.7.4-1 .8-1 1.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[10px] text-zinc-500">Help</span>
</button>
{helpOpen && (
@ -281,6 +305,7 @@ export function Toolbar() {
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
<button
type="button"
onClick={() => setHelpOpen(false)}
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
>

View File

@ -1,31 +1,25 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useCallback, useMemo } from "react";
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { showToast } from "@/components/Toaster";
import { Tooltip } from "@/components/Tooltip";
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
import { useShallow } from "zustand/react/shallow";
/** Stable selector: returns children, grandchild flag, and descendant count for a node */
function useHierarchyInfo(parentId: string) {
const childIds = useCanvasStore(
useCallback((s) => s.nodes.filter((n) => n.data.parentId === parentId).map((n) => n.id).join(","), [parentId])
/** Descendant count for the "N sub" badge children are first-class nodes
* rendered as full cards inside this one via React Flow's native parentId,
* so we don't need to subscribe to the actual child list here. */
function useDescendantCount(nodeId: string): number {
return useCanvasStore(
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
);
const children = useCanvasStore(
useShallow((s) => s.nodes.filter((n) => n.data.parentId === parentId))
}
function useHasChildren(nodeId: string): boolean {
return useCanvasStore(
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
);
const hasGrandchildren = useCanvasStore(
useCallback((s) => {
const ids = childIds.split(",").filter(Boolean);
return ids.length > 0 && ids.some((cid) => s.nodes.some((n) => n.data.parentId === cid));
}, [childIds])
);
const descendantCount = useCanvasStore(
useCallback((s) => countDescendants(parentId, s.nodes), [parentId])
);
return { children, hasGrandchildren, descendantCount };
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
@ -52,18 +46,26 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
const toggleNodeSelection = useCanvasStore((s) => s.toggleNodeSelection);
const isOnline = data.status === "online";
// Get children + hierarchy info (single stable selector avoids redundant re-renders)
const { children, hasGrandchildren, descendantCount } = useHierarchyInfo(id);
const hasChildren = children.length > 0;
// Children are first-class RF nodes now (rendered inside this one via
// React Flow's native parentId). We only need the count for the badge
// and a boolean so parent cards default to a larger size.
const hasChildren = useHasChildren(id);
const descendantCount = useDescendantCount(id);
const skills = getSkillNames(data.agentCard);
const handleExtract = useCallback(
(childId: string) => nestNode(childId, null),
[nestNode]
);
return (
<>
{/* NodeResizer visible only on the selected card. Lets the user
* drag any edge/corner to grow or shrink the workspace, which is
* useful on cards that contain nested child workspaces. */}
<NodeResizer
isVisible={isSelected}
minWidth={hasChildren ? 360 : 210}
minHeight={hasChildren ? 200 : 110}
lineClassName="!border-blue-500/40"
handleClassName="!w-2 !h-2 !bg-blue-500 !border !border-blue-300"
/>
<div
role="button"
tabIndex={0}
@ -79,9 +81,23 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (hasChildren) {
window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } }));
if (!hasChildren) return;
// A collapsed parent double-click EXPANDS first (flipping the
// collapsed flag + persisting it via the API). Once expanded,
// subsequent double-clicks zoom-to-team so the user can see
// the hierarchy fit in the viewport. Matches the user's ask:
// default-collapsed for clean first paint, one gesture reveals
// the subtree.
if (data.collapsed) {
const state = useCanvasStore.getState();
state.setCollapsed(id, false);
// Fire-and-forget persist so reload retains the expansion.
import("@/lib/api").then(({ api }) => {
api.patch(`/workspaces/${id}`, { collapsed: false }).catch(() => {});
});
return;
}
window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } }));
}}
onContextMenu={(e) => {
e.preventDefault();
@ -108,8 +124,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
}
}}
className={`
group relative rounded-xl
${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"}
group relative rounded-xl h-full w-full
${hasChildren && !data.collapsed ? "min-w-[360px] min-h-[200px]" : "min-w-[210px]"}
cursor-pointer overflow-hidden
transition-all duration-200 ease-out
${isDragTarget
@ -186,9 +202,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
);
})()}
{/* Role */}
{/* Role clamp to 2 lines. Without this, a verbose role
* description (common on org-template imports) lets the card
* grow arbitrarily tall, which wrecks the grid-slot layout
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
{data.role && (
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight">{data.role}</div>
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight line-clamp-2">{data.role}</div>
)}
{/* Skills */}
@ -214,10 +233,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
</div>
)}
{/* Embedded children — rendered INSIDE the parent node */}
{hasChildren && (
<EmbeddedTeam members={children} depth={0} onSelect={selectNode} onExtract={handleExtract} />
)}
{/* Children render as first-class React Flow nodes inside this
* card (parentId binding). No embedded TEAM MEMBERS list here
* just keep visual breathing room via the min-height above. */}
{/* Current task */}
{data.currentTask && (
@ -232,6 +250,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Needs restart banner */}
{data.needsRestart && !data.currentTask && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
@ -283,11 +302,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
/>
</div>
</>
);
}
const MAX_NESTING_DEPTH = 3;
/** Count all descendants (children + grandchildren + ...) */
function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], visited = new Set<string>()): number {
if (visited.has(nodeId)) return 0;
@ -300,6 +318,10 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
return count;
}
/** Maximum nesting depth for recursive TeamMemberChip rendering prevents
* infinite recursion on circular parentId references and keeps the UI readable. */
const MAX_NESTING_DEPTH = 3;
/** Subscribes to allNodes only when children exist — isolates re-renders from parent */
function EmbeddedTeam({ members, depth, onSelect, onExtract }: {
members: Node<WorkspaceNodeData>[];
@ -400,6 +422,7 @@ function TeamMemberChip({
{tierCfg.label}
</span>
<button
type="button"
aria-label={`Extract ${data.name} from team`}
title={`Extract ${data.name} from team`}
onClick={(e) => {

View File

@ -105,10 +105,64 @@ describe("AuthGate — authenticated state", () => {
});
});
describe("AuthGate — /cp/auth/* skip guard (redirect loop regression)", () => {
it("renders children without calling fetchSession or redirect when pathname starts with /cp/auth/", async () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockResolvedValue(null);
// Simulate being on the login page
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, pathname: "/cp/auth/login" },
});
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
});
// Children should render — AuthGate skips session fetch for auth paths
expect(result!.getByTestId("child")).toBeTruthy();
expect(mockFetchSession).not.toHaveBeenCalled();
expect(mockRedirectToLogin).not.toHaveBeenCalled();
});
it("renders children without calling redirect for /cp/auth/signup path", async () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockResolvedValue(null);
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, pathname: "/cp/auth/signup" },
});
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
});
expect(result!.getByTestId("child")).toBeTruthy();
expect(mockRedirectToLogin).not.toHaveBeenCalled();
});
});
describe("AuthGate — anonymous / redirect state", () => {
it("calls redirectToLogin when session fetch returns null", async () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockResolvedValue(null);
// Ensure pathname is NOT on /cp/auth/* so the redirect guard fires
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, pathname: "/dashboard" },
});
await act(async () => {
render(

View File

@ -19,11 +19,18 @@ vi.mock("@/lib/api", () => ({
api: { get: vi.fn(), put: vi.fn(), patch: vi.fn(), post: vi.fn() },
}));
const mockCanvasState = {
restartWorkspace: vi.fn(),
updateNodeData: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(() => ({
restartWorkspace: vi.fn(),
updateNodeData: vi.fn(),
})),
useCanvasStore: Object.assign(
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector(mockCanvasState as Record<string, unknown>)
),
{ getState: () => mockCanvasState }
),
}));
vi.mock("../tabs/config/secrets-section", () => ({

View File

@ -48,20 +48,12 @@ const mockStore = {
nodes: [] as Array<{ id: string; data: { parentId: string | null } }>,
};
// useCanvasStore.getState() is called directly by ContextMenu to read `nodes`
// for parent-filtering (see ContextMenu.tsx childNodes computation). The mock
// must expose both the selector-calling function form AND the .getState()
// form so production code using either pattern doesn't hit "not a function".
// Factory body runs under vi.mock's hoist — cannot reference outer scope,
// so we build the mock function inside and reach `mockStore` via `globalThis`.
vi.mock("@/store/canvas", () => {
const fn = vi.fn((selector: (s: typeof mockStore) => unknown) =>
selector(mockStore),
);
return {
useCanvasStore: Object.assign(fn, { getState: () => mockStore }),
};
});
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStore) => unknown) => selector(mockStore)),
{ getState: () => mockStore }
),
}));
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { ContextMenu } from "../ContextMenu";

View File

@ -6,11 +6,30 @@ import { CookieConsent, hasConsent } from "../CookieConsent";
const STORAGE_KEY = "molecule_cookie_consent";
// These tests lock the privacy-preserving default: the banner appears on
// first visit, clicking either button records a decision, and subsequent
// renders skip the banner until the policy version changes.
// first visit (SaaS mode), clicking either button records a decision, and
// subsequent renders skip the banner until the policy version changes.
//
// The banner is SaaS-only — it references moleculesai.app's hosted privacy
// policy and presumes GDPR/ePrivacy obligations that only apply to the
// hosted offering. Self-hosted / local-dev hosts must not see it. Most
// tests below simulate SaaS by overriding window.location.hostname; the
// "local-dev" test omits that override.
// setSaaSHostname rewrites window.location.hostname to look like a SaaS
// tenant subdomain so isSaaSTenant() returns true. Must run before
// CookieConsent mounts, otherwise its one-shot useEffect captures the
// localhost default. jsdom's location object is read-only via the normal
// setter but defineProperty lets us replace it for the scope of a test.
function setSaaSHostname(host = "acme.moleculesai.app") {
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, hostname: host },
});
}
beforeEach(() => {
window.localStorage.clear();
setSaaSHostname();
});
afterEach(() => {
@ -86,6 +105,28 @@ describe("CookieConsent", () => {
expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body");
});
it("does NOT render on local dev (non-SaaS hostname)", () => {
// Simulate `npm run dev` on localhost — isSaaSTenant() returns false
// and the banner must stay hidden. Regression test for PR #1871:
// a fresh-clone Canvas showing the hosted privacy banner on
// localhost:3000 was confusing for self-hosted users.
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, hostname: "localhost" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does NOT render on a LAN hostname (192.168.*, *.local)", () => {
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, hostname: "192.168.1.74" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("hasConsent", () => {

View File

@ -80,15 +80,16 @@ describe("CreateWorkspaceDialog — accessibility", () => {
// Non-SaaS build (jsdom hostname is localhost) shows all four tiers:
// T1 Sandboxed, T2 Standard, T3 Privileged, T4 Full Access.
expect(radios.length).toBe(4);
// T1 is default selection
// T3 is the default selection on non-SaaS hosts (see
// CreateWorkspaceDialog.tsx `defaultTier` comment).
const t1 = radios.find((r) => r.textContent?.includes("T1"));
const t2 = radios.find((r) => r.textContent?.includes("T2"));
expect(t1?.getAttribute("aria-checked")).toBe("true");
expect(t2?.getAttribute("aria-checked")).toBe("false");
// Click T2 and verify aria-checked flips
fireEvent.click(t2!);
const t3 = radios.find((r) => r.textContent?.includes("T3"));
expect(t3?.getAttribute("aria-checked")).toBe("true");
expect(t1?.getAttribute("aria-checked")).toBe("false");
// Click T1 and verify aria-checked flips
fireEvent.click(t1!);
await waitFor(() =>
expect(t2?.getAttribute("aria-checked")).toBe("true")
expect(t1?.getAttribute("aria-checked")).toBe("true")
);
});
@ -101,10 +102,10 @@ describe("CreateWorkspaceDialog — accessibility", () => {
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
// T1 is default selected (non-SaaS test env; SaaS would default to T4)
expect(t1.getAttribute("tabindex")).toBe("0");
// T3 is default selected (non-SaaS test env; SaaS would default to T4).
expect(t3.getAttribute("tabindex")).toBe("0");
expect(t1.getAttribute("tabindex")).toBe("-1");
expect(t2.getAttribute("tabindex")).toBe("-1");
expect(t3.getAttribute("tabindex")).toBe("-1");
expect(t4.getAttribute("tabindex")).toBe("-1");
});

View File

@ -28,6 +28,8 @@ vi.mock("@/lib/deploy-preflight", () => ({
return labels[key] ?? key;
},
}));
// a11y tests render the modal without a `providers` prop — it falls
// back to all-keys mode driven by the `missingKeys` array.
// ── Import after mocks ────────────────────────────────────────────────────────
@ -83,7 +85,7 @@ describe("MissingKeysModal — WCAG 2.1 dialog accessibility", () => {
const backdrop = document.querySelector('[aria-hidden="true"]');
expect(backdrop).toBeTruthy();
// Verify the backdrop is the full-screen overlay (has bg-black/70)
expect(backdrop?.className).toContain("bg-black");
expect(backdrop?.className).toContain("bg-black/70");
});
it("decorative warning SVG in header has aria-hidden='true'", () => {

View File

@ -37,6 +37,9 @@ vi.mock("@/lib/deploy-preflight", () => ({
return labels[key] ?? key;
},
}));
// Tests render the modal without a `providers` prop — the component
// falls back to the all-keys mode using the `missingKeys` array, which
// matches the contract these tests were written for.
// ── Suite 1: Visibility and ARIA ────────────────────────────────────────────
@ -265,7 +268,7 @@ describe("MissingKeysModal — save flow", () => {
onCancel={vi.fn()}
/>
);
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!;
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
@ -284,7 +287,7 @@ describe("MissingKeysModal — save flow", () => {
act(() => {
fireEvent.change(input, { target: { value: "sk-123" } });
});
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!;
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});

View File

@ -1,83 +0,0 @@
// @vitest-environment node
/**
* MissingKeysModal preflight logic tests.
* Component rendering tested in MissingKeysModal.component.test.tsx.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
global.fetch = vi.fn();
import {
getRequiredKeys,
findMissingKeys,
getKeyLabel,
checkDeploySecrets,
RUNTIME_REQUIRED_KEYS,
} from "../../lib/deploy-preflight";
beforeEach(() => {
vi.clearAllMocks();
});
describe("MissingKeysModal preflight logic", () => {
it("identifies missing keys for langgraph runtime", () => {
const missing = findMissingKeys("langgraph", new Set<string>());
expect(missing).toEqual(["OPENAI_API_KEY"]);
});
it("identifies missing keys for claude-code runtime", () => {
const missing = findMissingKeys("claude-code", new Set<string>());
expect(missing).toEqual(["ANTHROPIC_API_KEY"]);
});
it("generates correct labels for modal display", () => {
const missing = findMissingKeys("langgraph", new Set<string>());
const labels = missing.map((k) => ({ key: k, label: getKeyLabel(k) }));
expect(labels).toEqual([{ key: "OPENAI_API_KEY", label: "OpenAI API Key" }]);
});
it("returns no missing keys when all are configured", () => {
const missing = findMissingKeys("langgraph", new Set(["OPENAI_API_KEY"]));
expect(missing).toEqual([]);
});
it("pre-deploy check returns ok=false and correct missing keys", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.runtime).toBe("langgraph");
});
it("pre-deploy check returns ok=true when keys are present", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([{ key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" }]),
} as Response);
const result = await checkDeploySecrets("claude-code");
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
});
it("handles all runtimes correctly for modal data construction", () => {
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
for (const runtime of runtimes) {
const requiredKeys = getRequiredKeys(runtime);
const missing = findMissingKeys(runtime, new Set<string>());
const labels = missing.map((k) => getKeyLabel(k));
expect(requiredKeys.length).toBeGreaterThan(0);
expect(missing).toEqual(requiredKeys);
expect(labels.length).toBe(requiredKeys.length);
for (const label of labels) {
expect(label.length).toBeGreaterThan(0);
}
}
});
});

View File

@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
// Tests for the default-collapsed + expand-on-click behavior of the
// org templates drawer. Before this change the section rendered all
// org cards inline, which pushed the individual workspace templates
// off-screen when there were ≥3 orgs on disk. Collapsed-by-default
// keeps the scroll focused on the primary deploy path.
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn().mockResolvedValue([
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
]),
post: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("../Spinner", () => ({ Spinner: () => null }));
vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
import { OrgTemplatesSection } from "../TemplatePalette";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("OrgTemplatesSection — collapse/expand", () => {
it("renders collapsed by default — org cards are NOT in the DOM", async () => {
render(<OrgTemplatesSection />);
// The header toggle is visible immediately…
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
expect(toggle).toBeTruthy();
expect(toggle.getAttribute("aria-expanded")).toBe("false");
// …and the count appears after loadOrgs resolves.
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
// But none of the individual org cards should be rendered yet.
expect(screen.queryByText("Free Beats All")).toBeNull();
expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
});
it("clicking the header reveals the org cards", async () => {
render(<OrgTemplatesSection />);
// Wait for the count so we know loadOrgs finished.
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
// Expand.
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
// Org cards now visible.
expect(screen.getByText("Free Beats All")).toBeTruthy();
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
});
it("clicking the header again collapses back", async () => {
render(<OrgTemplatesSection />);
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
fireEvent.click(toggle); // expand
expect(screen.getByText("Free Beats All")).toBeTruthy();
fireEvent.click(toggle); // collapse
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
expect(screen.queryByText("Free Beats All")).toBeNull();
});
});

View File

@ -36,6 +36,10 @@ const mockStoreState = {
panelTab: "chat",
setPanelTab: mockSetPanelTab,
selectNode: vi.fn(),
// Consumed by SidePanel's useEffect — publishes the drag-resized
// width to the store so Toolbar can re-centre itself on the
// remaining canvas area when the panel is open.
setSidePanelWidth: vi.fn(),
nodes: [
{
id: "ws-1",

View File

@ -73,6 +73,26 @@ describe("importOrgTemplate", () => {
mockFetch.mockRejectedValueOnce(new Error("offline"));
await expect(importOrgTemplate("x")).rejects.toThrow("offline");
});
it("treats 2xx with `error` field as a failure (StatusMultiStatus partial)", async () => {
// Server returns 207 — `api.post` treats the 2xx as success and
// returns the body. Without the post-check, a partial failure
// (0 workspaces created) would surface as a green "Imported"
// toast and the user would see no canvas change.
mockFetch.mockResolvedValueOnce({
ok: true,
status: 207,
json: async () => ({
org: "Data Team",
workspaces: [],
count: 0,
error: 'pq: column "collapsed" of relation "workspaces" does not exist',
}),
});
await expect(importOrgTemplate("data-team")).rejects.toThrow(
/collapsed.*relation.*workspaces.*created 0 workspaces/,
);
});
});
describe("module exports", () => {

View File

@ -1,202 +0,0 @@
// @vitest-environment jsdom
/**
* WorkspaceNode a11y tests issue #831
*
* Covers the TeamMemberChip sub-component (rendered inside a parent workspace
* node when that node has children):
* - role="button" is present
* - aria-label="Select <name>" is present
* - pressing Enter triggers onSelect with the child's id
* - pressing Space triggers onSelect with the child's id
* - the eject button has aria-label="Extract from team"
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
// ── Mock @xyflow/react (Handles) ──────────────────────────────────────────────
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Top: "top", Bottom: "bottom" },
}));
// ── Mock Tooltip (passthrough) ────────────────────────────────────────────────
vi.mock("@/components/Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// ── Mock Toaster ──────────────────────────────────────────────────────────────
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
// ── Mock design tokens ────────────────────────────────────────────────────────
vi.mock("@/lib/design-tokens", () => ({
STATUS_CONFIG: {
online: {
dot: "bg-emerald-400",
glow: "",
bar: "from-emerald-950/30",
label: "Online",
},
offline: {
dot: "bg-zinc-500",
glow: "",
bar: "from-zinc-900",
label: "Offline",
},
degraded: {
dot: "bg-amber-400",
glow: "",
bar: "from-amber-950/30",
label: "Degraded",
},
provisioning: {
dot: "bg-sky-400",
glow: "",
bar: "from-sky-950/30",
label: "Provisioning",
},
failed: {
dot: "bg-red-400",
glow: "",
bar: "from-red-950/30",
label: "Failed",
},
},
TIER_CONFIG: {
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
2: { label: "T2", color: "text-zinc-400 bg-zinc-800" },
3: { label: "T3", color: "text-zinc-400 bg-zinc-800" },
},
}));
// ── Store state with a parent + one child ────────────────────────────────────
const mockSelectNode = vi.fn();
const mockOpenContextMenu = vi.fn();
const mockNestNode = vi.fn();
const PARENT_ID = "ws-parent";
const CHILD_ID = "ws-child";
const PARENT_DATA = {
name: "Parent Workspace",
status: "online",
tier: 1 as const,
role: "Manager",
parentId: null,
needsRestart: false,
currentTask: null,
activeTasks: 0,
agentCard: null,
runtime: "langgraph",
lastSampleError: null,
};
const CHILD_DATA = {
name: "Child Workspace",
status: "online",
tier: 1 as const,
role: "Worker",
parentId: PARENT_ID,
needsRestart: false,
currentTask: null,
activeTasks: 0,
agentCard: null,
runtime: "langgraph",
lastSampleError: null,
};
const ALL_NODES = [
{ id: PARENT_ID, position: { x: 0, y: 0 }, data: PARENT_DATA },
{ id: CHILD_ID, position: { x: 0, y: 0 }, data: CHILD_DATA },
];
const mockStoreState = {
nodes: ALL_NODES,
selectedNodeId: null,
dragOverNodeId: null,
selectNode: mockSelectNode,
openContextMenu: mockOpenContextMenu,
nestNode: mockNestNode,
restartWorkspace: vi.fn(() => Promise.resolve()),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
{ getState: () => mockStoreState }
),
}));
// ── Import component AFTER mocks ──────────────────────────────────────────────
import { WorkspaceNode } from "../WorkspaceNode";
// ── Helper ────────────────────────────────────────────────────────────────────
function renderParentNode() {
// WorkspaceNode's full NodeProps has many optional fields; we only need id+data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(<WorkspaceNode id={PARENT_ID} data={PARENT_DATA as any} />);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("WorkspaceNode — TeamMemberChip a11y (issue #831)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("TeamMemberChip renders with role='button'", () => {
renderParentNode();
// The parent WorkspaceNode div is role=button (aria-label contains the name),
// and the chip is a separate role=button with aria-label starting with "Select"
const chip = screen.getByRole("button", {
name: "Select Child Workspace",
});
expect(chip).toBeTruthy();
});
it("TeamMemberChip has aria-label='Select <name>'", () => {
renderParentNode();
const chip = screen.getByRole("button", {
name: "Select Child Workspace",
});
expect(chip.getAttribute("aria-label")).toBe("Select Child Workspace");
});
it("pressing Enter on TeamMemberChip calls selectNode with the child's id", () => {
renderParentNode();
const chip = screen.getByRole("button", {
name: "Select Child Workspace",
});
fireEvent.keyDown(chip, { key: "Enter" });
expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID);
});
it("pressing Space on TeamMemberChip calls selectNode with the child's id", () => {
renderParentNode();
const chip = screen.getByRole("button", {
name: "Select Child Workspace",
});
fireEvent.keyDown(chip, { key: " " });
expect(mockSelectNode).toHaveBeenCalledWith(CHILD_ID);
});
it("eject button has aria-label='Extract <name> from team'", () => {
renderParentNode();
const ejectBtn = screen.getByRole("button", {
name: "Extract Child Workspace from team",
});
expect(ejectBtn).toBeTruthy();
});
});

View File

@ -1,190 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for issue #854 TeamMemberChip eject button:
* - aria-label must be dynamic: `Extract ${childName} from team`
* - title must be dynamic: `Extract ${childName} from team`
* - EjectIcon svg must carry aria-hidden="true"
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import type { Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Mock @xyflow/react ─────────────────────────────────────────────────────────
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Bottom: "bottom", Top: "top" },
useReactFlow: vi.fn(),
}));
// ── Mock Toaster ───────────────────────────────────────────────────────────────
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Mock Tooltip ───────────────────────────────────────────────────────────────
vi.mock("@/components/Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// ── Mock design tokens ─────────────────────────────────────────────────────────
vi.mock("@/lib/design-tokens", () => ({
STATUS_CONFIG: {
online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" },
offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" },
provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" },
degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" },
failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" },
paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" },
},
TIER_CONFIG: {
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
2: { label: "T2", color: "text-blue-400 bg-blue-900/40" },
},
}));
// ── Canvas store mock state ────────────────────────────────────────────────────
const PARENT_ID = "parent-ws";
const CHILD_ID = "child-ws";
const CHILD_NAME = "Child Workspace";
function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
return {
name: "Test WS",
role: "agent",
tier: 1,
status: "online",
agentCard: null,
url: "http://localhost:9000",
parentId: null,
activeTasks: 0,
lastErrorRate: 0,
lastSampleError: "",
uptimeSeconds: 60,
currentTask: "",
collapsed: false,
runtime: "",
needsRestart: false,
budgetLimit: null,
...overrides,
} as WorkspaceNodeData;
}
const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null });
const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID });
const allNodes: Node<WorkspaceNodeData>[] = [
{ id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData },
{ id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true },
];
// Build a selector-compatible mock of useCanvasStore
const mockStoreState = {
nodes: allNodes,
edges: [],
selectedNodeId: null,
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
searchOpen: false,
viewport: { x: 0, y: 0, zoom: 1 },
selectNode: vi.fn(),
openContextMenu: vi.fn(),
nestNode: vi.fn(),
isDescendant: vi.fn(() => false),
restartWorkspace: vi.fn(),
setPanelTab: vi.fn(),
selectedNodeIds: new Set<string>(),
toggleNodeSelection: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
{ getState: () => mockStoreState }
),
}));
// ── Mock zustand/react/shallow ─────────────────────────────────────────────────
vi.mock("zustand/react/shallow", () => ({
useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn,
}));
// ── Import component AFTER mocks ───────────────────────────────────────────────
import { WorkspaceNode } from "../WorkspaceNode";
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderParentNode() {
return render(
<WorkspaceNode
id={PARENT_ID}
data={parentNodeData}
// NodeProps — all required fields included; React Flow internals unused in mock env
type="workspaceNode"
selected={false}
isConnectable={true}
zIndex={0}
positionAbsoluteX={0}
positionAbsoluteY={0}
dragging={false}
draggable={false}
selectable={false}
deletable={false}
/>
);
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("TeamMemberChip eject button — aria-label (issue #854)", () => {
it("eject button has a dynamic aria-label containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`);
});
});
describe("TeamMemberChip eject button — title tooltip (issue #854)", () => {
it("eject button has a dynamic title tooltip containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`);
});
it("aria-label and title are identical (both use child workspace name)", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title"));
});
});
describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => {
it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
const svg = ejectBtn?.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
});

View File

@ -71,11 +71,14 @@ describe("Toolbar help panel — zoom shortcut entry", () => {
expect(src).toContain("Zoom canvas to fit a team node");
});
it("Canvas.tsx Z key handler guards against input elements", async () => {
it("Keyboard shortcuts hook guards against input elements", async () => {
const { readFileSync } = await import("fs");
const { join } = await import("path");
// After the canvas split (commit c5abed98 → f3423a51 series), the
// Z-key / hierarchy / zoom shortcuts moved out of Canvas.tsx into
// the useKeyboardShortcuts hook under src/components/canvas/.
const src = readFileSync(
join(__dirname, "../../components/Canvas.tsx"),
join(__dirname, "../../components/canvas/useKeyboardShortcuts.ts"),
"utf8"
);
expect(src).toContain('e.key === "z" || e.key === "Z"');

View File

@ -26,9 +26,16 @@ vi.mock("@/lib/api", () => ({
},
}));
const mockCanvasTabState = {
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({ setPanelTab: vi.fn() })
useCanvasStore: Object.assign(
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector(mockCanvasTabState as Record<string, unknown>)
),
{ getState: () => mockCanvasTabState }
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));

View File

@ -0,0 +1,83 @@
"use client";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import {
defaultChildSlot,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
} from "@/store/canvas-topology";
/**
* Floating affordance that tracks the current drag target. Two visuals
* are layered on top of React Flow, both in screen space:
*
* 1. Ghost preview dashed outline at the next default grid slot
* inside the target parent. Whimsical-style: users see exactly
* where the card will land before releasing.
* 2. Text badge "Drop into: <name>" floating above the target. The
* coloured outline alone is ambiguous on dense canvases; spelling
* the name out is the Mural pattern.
*
* Colour alone isn't an accessible cue, so the pair (outline + label)
* is deliberate.
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
if (!internal) return null;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const badge = flowToScreenPosition({ x: abs.x + w / 2, y: abs.y });
const slot = defaultChildSlot(childCount);
const slotTL = flowToScreenPosition({ x: abs.x + slot.x, y: abs.y + slot.y });
const slotBR = flowToScreenPosition({
x: abs.x + slot.x + CHILD_DEFAULT_WIDTH,
y: abs.y + slot.y + CHILD_DEFAULT_HEIGHT,
});
// Clip: don't draw the ghost if its rect falls entirely outside the
// parent (can happen when a parent is smaller than one default slot).
const parentTL = flowToScreenPosition({ x: abs.x, y: abs.y });
const parentBR = flowToScreenPosition({ x: abs.x + w, y: abs.y + h });
const ghostVisible =
slotBR.x > parentTL.x &&
slotTL.x < parentBR.x &&
slotBR.y > parentTL.y &&
slotTL.y < parentBR.y;
return (
<>
{ghostVisible && (
<div
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
top: slotTL.y,
width: slotBR.x - slotTL.x,
height: slotBR.y - slotTL.y,
}}
/>
)}
<div
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
</div>
</>
);
}

View File

@ -0,0 +1,74 @@
import type { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
/**
* Hysteresis threshold for drag-out detach. A child only un-nests from
* its parent once at least this fraction of its bounding box lies
* outside the parent's bbox a twitchy release 1px past the edge stays
* nested. Miro / tldraw use roughly 20-30%; 20% feels responsive.
*/
export const DETACH_FRACTION = 0.2;
type InternalNode = ReturnType<ReturnType<typeof useReactFlow>["getInternalNode"]>;
type GetInternalNode = (id: string) => InternalNode;
/**
* True when the child has moved far enough outside its parent's bbox
* that the gesture is unambiguously an un-nest. Returns true when we
* can't measure either node (conservative fall-back matches the
* original behaviour).
*/
export function shouldDetach(
childId: string,
parentId: string,
getInternalNode: GetInternalNode,
): boolean {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return true;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const cx = c.internals.positionAbsolute;
const px = p.internals.positionAbsolute;
const overlapW =
Math.max(0, Math.min(cx.x + cw, px.x + pw) - Math.max(cx.x, px.x));
const overlapH =
Math.max(0, Math.min(cx.y + ch, px.y + ph) - Math.max(cx.y, px.y));
const outsideFractionX = 1 - overlapW / cw;
const outsideFractionY = 1 - overlapH / ch;
return outsideFractionX > DETACH_FRACTION || outsideFractionY > DETACH_FRACTION;
}
/**
* Snap a child back so its bbox is fully inside the parent's bounds.
* Called on drag-stop when the user drifted slightly past the edge
* without holding Alt or Cmd the canvas treats the gesture as a
* plain move rather than an un-nest.
*/
export function clampChildIntoParent(
childId: string,
parentId: string,
getInternalNode: GetInternalNode,
) {
const c = getInternalNode(childId);
const p = getInternalNode(parentId);
if (!c || !p) return;
const cw = c.measured?.width ?? c.width ?? 220;
const ch = c.measured?.height ?? c.height ?? 120;
const pw = p.measured?.width ?? p.width ?? 220;
const ph = p.measured?.height ?? p.height ?? 120;
const { nodes } = useCanvasStore.getState();
const cur = nodes.find((n) => n.id === childId);
if (!cur) return;
const rel = cur.position;
const clampedX = Math.max(0, Math.min(rel.x, pw - cw));
const clampedY = Math.max(0, Math.min(rel.y, ph - ch));
if (clampedX === rel.x && clampedY === rel.y) return;
useCanvasStore.setState({
nodes: nodes.map((n) =>
n.id === childId ? { ...n, position: { x: clampedX, y: clampedY } } : n,
),
});
}

View File

@ -0,0 +1,141 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import {
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
} from "@/store/canvas-topology";
/**
* Wires the two canvas-wide CustomEvent listeners and the viewport
* save/restore bookkeeping so Canvas.tsx doesn't have to.
*
* - `molecule:pan-to-node` scroll viewport onto a specific node
* without forcing a specific zoom level (fitView adapts to current).
* - `molecule:zoom-to-team` fit the viewport to a parent + its
* direct children, with a small padding.
*
* Also returns an `onMoveEnd` handler that debounces viewport saves so
* the backend isn't spammed with pans.
*/
export function useCanvasViewport() {
const { fitBounds, fitView } = useReactFlow();
const saveViewport = useCanvasStore((s) => s.saveViewport);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const autoFitTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Tracks whether any workspace was provisioning on the previous
// render so we can detect the boundary when the last one finishes
// and auto-fit the viewport around the whole tree.
const hadProvisioningRef = useRef(false);
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current);
clearTimeout(panTimerRef.current);
clearTimeout(autoFitTimerRef.current);
};
}, []);
// Auto-fit the viewport once all workspaces finish provisioning. Org
// imports land dozens of new nodes off-screen; without a follow-up
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
const provisioningCount = useCanvasStore(
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
);
const nodeCount = useCanvasStore((s) => s.nodes.length);
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
const wasProvisioning = hadProvisioningRef.current;
hadProvisioningRef.current = hasProvisioning;
if (wasProvisioning && !hasProvisioning && nodeCount > 0) {
clearTimeout(autoFitTimerRef.current);
// 1200ms settle delay: lets React Flow's DOM measurement pass
// resize newly-online parents before we compute bounds.
// Measuring too early gives us the pre-render skeleton bbox and
// fitView zooms to that smaller-than-real rectangle.
autoFitTimerRef.current = setTimeout(() => {
fitView({
duration: 1200,
padding: 0.25,
// Cap zoom-in: a small tree (2-3 nodes) would otherwise end
// up at the 2x maxZoom, visually implying "something is
// wrong". 0.8 reads like "here's your whole org" even when
// the tree is small.
maxZoom: 0.8,
// Cap zoom-out: fitView would fall back to the component's
// minZoom=0.1 on a sparse/outlier layout, leaving the user
// staring at a postage-stamp canvas. 0.25 is the floor.
minZoom: 0.25,
});
}, 1200);
}
}, [provisioningCount, nodeCount, fitView]);
// Pan to a newly deployed / targeted workspace. 100ms delay so React
// Flow has time to measure a just-rendered node.
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
clearTimeout(panTimerRef.current);
panTimerRef.current = setTimeout(() => {
fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
}, 100);
};
window.addEventListener("molecule:pan-to-node", handler);
return () => window.removeEventListener("molecule:pan-to-node", handler);
}, [fitView]);
// Zoom to a team: fit the parent + its direct children in view.
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent).detail;
const state = useCanvasStore.getState();
const children = state.nodes.filter((n) => n.data.parentId === nodeId);
if (children.length === 0) return;
const parent = state.nodes.find((n) => n.id === nodeId);
const allNodes = parent ? [parent, ...children] : children;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const n of allNodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + CHILD_DEFAULT_WIDTH);
maxY = Math.max(maxY, n.position.y + CHILD_DEFAULT_HEIGHT);
}
fitBounds(
{
x: minX - 50,
y: minY - 50,
width: maxX - minX + 100,
height: maxY - minY + 100,
},
{ padding: 0.2, duration: 500 },
);
};
window.addEventListener("molecule:zoom-to-team", handler);
return () => window.removeEventListener("molecule:zoom-to-team", handler);
}, [fitBounds]);
const onMoveEnd = useCallback(
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveViewport(vp.x, vp.y, vp.zoom);
}, 1000);
},
[saveViewport],
);
return { onMoveEnd };
}

View File

@ -0,0 +1,284 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
useReactFlow,
type Node,
type OnNodeDrag,
} from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { clampChildIntoParent, shouldDetach } from "./dragUtils";
type WorkspaceNode = Node<WorkspaceNodeData>;
export interface PendingNestState {
nodeId: string;
targetId: string | null;
nodeName: string;
targetName: string;
}
interface DragHandlers {
onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>>;
onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>>;
onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>>;
pendingNest: PendingNestState | null;
confirmNest: () => void;
cancelNest: () => void;
}
/**
* Encapsulates every drag gesture on the canvas:
*
* - On drag start, snapshot the modifier keys (Alt / Cmd-Meta) and
* remember which parent the node lived in so we can detect a
* re-parent on release.
* - On drag (mousemove), compute the best drop target via an
* absolute-bounds hit test and publish it via setDragOverNode so
* WorkspaceNode can render the highlight + DropTargetBadge can
* render its label + ghost preview.
* - On drag stop, decide one of: nest into new parent, un-nest, soft
* clamp back inside current parent, or plain move based on
* modifier keys and hysteresis. Persist the absolute position,
* then run one commit-on-release grow pass on the parent chain.
*/
export function useDragHandlers(): DragHandlers {
const setDragOverNode = useCanvasStore((s) => s.setDragOverNode);
const savePosition = useCanvasStore((s) => s.savePosition);
const nestNode = useCanvasStore((s) => s.nestNode);
const batchNest = useCanvasStore((s) => s.batchNest);
const isDescendant = useCanvasStore((s) => s.isDescendant);
const { getInternalNode } = useReactFlow();
const dragModifiersRef = useRef<{ alt: boolean; meta: boolean }>({
alt: false,
meta: false,
});
// Remember where the dragged node started so we can put it back on
// cancel. React Flow tracks only the current position during drag;
// if the user drags out → "Extract?" dialog → Cancel, we want the
// card to go back inside its parent at its original coords rather
// than stay dangling at the cancel-time position.
const dragStartStateRef = useRef<{
nodeId: string;
parentId: string | null;
position: { x: number; y: number };
} | null>(null);
const [pendingNest, setPendingNest] = useState<PendingNestState | null>(null);
// Absolute-bounds hit test. Tiebreakers in order: highest zIndex
// first (matches what the user sees in front after Cmd+] reorder),
// deepest tree depth second, smallest area third. Depths are
// pre-computed once per call so the whole pass stays O(n).
const findDropTarget = useCallback(
(draggedId: string, point: { x: number; y: number }): string | null => {
const all = useCanvasStore.getState().nodes;
const depthById = new Map<string, number>();
for (const n of all) {
depthById.set(
n.id,
n.data.parentId ? (depthById.get(n.data.parentId) ?? 0) + 1 : 0,
);
}
let best:
| { id: string; depth: number; zIndex: number; area: number }
| null = null;
for (const n of all) {
if (n.id === draggedId || isDescendant(draggedId, n.id)) continue;
const internal = getInternalNode(n.id);
if (!internal) continue;
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? n.width ?? 220;
const h = internal.measured?.height ?? n.height ?? 120;
if (point.x < abs.x || point.x > abs.x + w) continue;
if (point.y < abs.y || point.y > abs.y + h) continue;
const depth = depthById.get(n.id) ?? 0;
const z = n.zIndex ?? 0;
const area = w * h;
if (
!best ||
z > best.zIndex ||
(z === best.zIndex && depth > best.depth) ||
(z === best.zIndex && depth === best.depth && area < best.area)
) {
best = { id: n.id, depth, zIndex: z, area };
}
}
return best?.id ?? null;
},
[getInternalNode, isDescendant],
);
const onNodeDragStart: OnNodeDrag<WorkspaceNode> = useCallback(
(event, node) => {
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
dragStartStateRef.current = {
nodeId: node.id,
parentId: node.data.parentId,
position: { x: node.position.x, y: node.position.y },
};
},
[],
);
const onNodeDrag: OnNodeDrag<WorkspaceNode> = useCallback(
(event, node) => {
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
const internal = getInternalNode(node.id);
if (!internal) {
setDragOverNode(null);
return;
}
const abs = internal.internals.positionAbsolute;
const w = internal.measured?.width ?? 220;
const h = internal.measured?.height ?? 120;
const center = { x: abs.x + w / 2, y: abs.y + h / 2 };
setDragOverNode(findDropTarget(node.id, center));
},
[findDropTarget, getInternalNode, setDragOverNode],
);
const onNodeDragStop: OnNodeDrag<WorkspaceNode> = useCallback(
(event, node) => {
const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState();
setDragOverNode(null);
const nodeName = node.data.name;
const currentParentId = node.data.parentId;
const forceDetach =
event.metaKey || event.ctrlKey || dragModifiersRef.current.meta;
const droppingIntoAnotherParent =
!!dragOverNodeId && dragOverNodeId !== currentParentId;
// Past the 20 %-overlap hysteresis? Treat the gesture as a
// deliberate drag-out. Below that threshold we soft-clamp the
// child back inside so a twitchy release doesn't un-nest
// accidentally (same intent as before, just: plain drag works
// without a modifier now).
const pastHysteresis =
!!currentParentId &&
shouldDetach(node.id, currentParentId, getInternalNode);
if (droppingIntoAnotherParent) {
// Explicit drop onto another workspace always wins over
// clamp/detach — the user pointed at a new target.
const targetNode = allNodes.find((n) => n.id === dragOverNodeId);
const targetName = targetNode?.data.name || "Unknown";
setPendingNest({
nodeId: node.id,
targetId: dragOverNodeId,
nodeName,
targetName,
});
} else if (currentParentId && (forceDetach || pastHysteresis)) {
// Dragged past the edge (or Cmd-held as a force override): the
// user wants out of the parent. Confirm the un-nest.
const parentNode = allNodes.find((n) => n.id === currentParentId);
const parentName = parentNode?.data.name || "Unknown";
setPendingNest({
nodeId: node.id,
targetId: null,
nodeName,
targetName: parentName,
});
} else if (currentParentId) {
// Still inside parent but the drag ended slightly past the
// edge (under 20 % outside). Snap back in so the card doesn't
// visually spill — Miro frame behaviour.
clampChildIntoParent(node.id, currentParentId, getInternalNode);
}
const internal = getInternalNode(node.id);
const abs = internal?.internals.positionAbsolute ?? node.position;
savePosition(node.id, abs.x, abs.y);
useCanvasStore.getState().growParentsToFitChildren();
},
[getInternalNode, savePosition, setDragOverNode],
);
const confirmNest = useCallback(() => {
if (!pendingNest) return;
// Close the dialog before dispatching the async store action so a
// second drag can't kick off a competing batch while this one is
// still mid-flight. The store actions surface their own errors via
// showToast, so `void` is the right pattern here.
const pending = pendingNest;
setPendingNest(null);
dragStartStateRef.current = null;
const state = useCanvasStore.getState();
if (
state.selectedNodeIds.size > 1 &&
state.selectedNodeIds.has(pending.nodeId)
) {
void batchNest(Array.from(state.selectedNodeIds), pending.targetId);
} else {
void nestNode(pending.nodeId, pending.targetId);
}
}, [pendingNest, nestNode, batchNest]);
const cancelNest = useCallback(() => {
// Restore the dragged card to wherever it started. Without this,
// a user who drags a child out of a parent then clicks Cancel
// leaves the card stranded outside the parent with no visual
// parent link — a state that doesn't match any save-backed
// truth (the DB position was already written on drag-stop).
const start = dragStartStateRef.current;
if (start) {
const { nodes } = useCanvasStore.getState();
// Strip the parent's explicit width/height while we're restoring
// the child. `growParentsToFitChildren` ran on drag-stop to fit
// the then-outside child, so without this step the parent stays
// visibly grown even after the child snaps back inside.
// Clearing width/height lets React Flow re-measure from CSS
// min-width/min-height, which collapses to the actual content.
const nextNodes = nodes.map((n) => {
if (n.id === start.nodeId) {
return { ...n, position: start.position };
}
if (start.parentId && n.id === start.parentId) {
const { width: _w, height: _h, ...rest } = n;
void _w; void _h;
return rest as typeof n;
}
return n;
});
useCanvasStore.setState({ nodes: nextNodes });
// Write the restore back to the DB so a reload shows the same
// position. Convert the stored relative position back to absolute
// via the parent's absolute origin before saving.
const parent = start.parentId
? nodes.find((n) => n.id === start.parentId)
: null;
const parentInternal = start.parentId
? getInternalNode(start.parentId)
: null;
const parentAbs = parentInternal?.internals.positionAbsolute ?? {
x: parent?.position.x ?? 0,
y: parent?.position.y ?? 0,
};
savePosition(
start.nodeId,
start.position.x + parentAbs.x,
start.position.y + parentAbs.y,
);
}
dragStartStateRef.current = null;
setPendingNest(null);
}, [getInternalNode, savePosition]);
return {
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
pendingNest,
confirmNest,
cancelNest,
};
}

View File

@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas";
/**
* Canvas-wide keyboard shortcuts. All bound to the document window so
* they work regardless of focused node, except when the user is typing
* into an input (`inInput` short-circuits handling).
*
* Esc close context menu, clear selection, deselect
* Enter descend into selected node's first child
* Shift+Enter ascend to selected node's parent
* Cmd/Ctrl+] bump selected node forward in z-order
* Cmd/Ctrl+[ bump selected node backward in z-order
* Z zoom-to-team if the selected node has children
*/
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") {
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
} else if (state.selectedNodeIds.size > 0) {
state.clearSelection();
} else if (state.selectedNodeId) {
state.selectNode(null);
}
}
// Figma-style hierarchy navigation. Skipped when the user is
// typing so Enter can still submit forms.
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
if (e.shiftKey) {
const sel = state.nodes.find((n) => n.id === id);
const parentId = sel?.data.parentId ?? null;
if (parentId) state.selectNode(parentId);
} else {
const firstChild = state.nodes.find((n) => n.data.parentId === id);
if (firstChild) state.selectNode(firstChild.id);
}
}
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
(e.key === "]" || e.key === "[")
) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
if (!id) return;
state.bumpZOrder(id, e.key === "]" ? 1 : -1);
}
if (!inInput && (e.key === "z" || e.key === "Z")) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
const hasChildren = state.nodes.some(
(n) => n.data.parentId === selectedId,
);
if (hasChildren) {
window.dispatchEvent(
new CustomEvent("molecule:zoom-to-team", {
detail: { nodeId: selectedId },
}),
);
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}

View File

@ -125,6 +125,7 @@ export function OrgTokensTab() {
onChange={(e) => setNameInput(e.target.value)}
placeholder="Label (e.g. zapier, my-ci)"
maxLength={100}
aria-label="Organization API key label"
className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600"
/>
<button

View File

@ -62,6 +62,7 @@ function GearIcon() {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />

View File

@ -60,18 +60,36 @@ export function ChannelsTab({ workspaceId }: Props) {
const allowedUsersId = useId();
const load = useCallback(async () => {
try {
const [chRes, adRes] = await Promise.all([
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
api.get<ChannelAdapter[]>(`/channels/adapters`),
]);
setChannels(Array.isArray(chRes) ? chRes : []);
setAdapters(Array.isArray(adRes) ? adRes : []);
} catch {
/* ignore */
} finally {
setLoading(false);
// Fetch channels and adapters independently so a failure in one
// doesn't blank the other. Previously a single Promise.all + silent
// catch meant ANY request failing left both `channels` and
// `adapters` empty — the user saw a "+ Connect" button with no
// platform options, with no clue why.
const [chResult, adResult] = await Promise.allSettled([
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
api.get<ChannelAdapter[]>(`/channels/adapters`),
]);
const errors: string[] = [];
if (chResult.status === "fulfilled") {
setChannels(Array.isArray(chResult.value) ? chResult.value : []);
} else {
console.warn("ChannelsTab: channels load failed", chResult.reason);
errors.push("connected channels");
}
if (adResult.status === "fulfilled") {
setAdapters(Array.isArray(adResult.value) ? adResult.value : []);
} else {
console.warn("ChannelsTab: adapters load failed", adResult.reason);
errors.push("platforms");
}
// Surface BOTH failure modes so the user can distinguish
// "no channels configured" from "API unreachable".
if (errors.length > 0) {
setError(`Failed to load ${errors.join(" and ")} — try refreshing`);
} else {
setError("");
}
setLoading(false);
}, [workspaceId]);
useEffect(() => { load(); }, [load]);

View File

@ -6,7 +6,8 @@ import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { WS_URL } from "@/store/socket";
import { type ChatMessage, createMessage } from "./chat/types";
import { closeWebSocketGracefully } from "@/lib/ws-close";
import { type ChatMessage, createMessage, appendMessageDeduped } from "./chat/types";
import { extractResponseText, extractRequestText } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { runtimeDisplayName } from "@/lib/runtime-names";
@ -143,12 +144,28 @@ export function ChatTab({ workspaceId, data }: Props) {
</button>
</div>
{/* Content both panels are always in the DOM so aria-controls targets exist.
The inactive panel is hidden via the HTML `hidden` attribute (removed from
display and accessibility tree, but present in the DOM for WCAG 4.1.2). */}
<div id="chat-panel-my-chat" role="tabpanel" aria-labelledby="chat-tab-my-chat" hidden={subTab !== "my-chat"} className="flex-1 overflow-hidden flex flex-col">
Inactive panel is hidden via a conditional `hidden` Tailwind class
(display: none) because the native HTML `hidden` attribute is
overridden by the panel's own `flex` utility — that's why both
sections used to render stacked. */}
<div
id="chat-panel-my-chat"
role="tabpanel"
aria-labelledby="chat-tab-my-chat"
className={`flex-1 overflow-hidden flex-col ${
subTab === "my-chat" ? "flex" : "hidden"
}`}
>
<MyChatPanel workspaceId={workspaceId} data={data} />
</div>
<div id="chat-panel-agent-comms" role="tabpanel" aria-labelledby="chat-tab-agent-comms" hidden={subTab !== "agent-comms"} className="flex-1 overflow-hidden flex flex-col">
<div
id="chat-panel-agent-comms"
role="tabpanel"
aria-labelledby="chat-tab-agent-comms"
className={`flex-1 overflow-hidden flex-col ${
subTab === "agent-comms" ? "flex" : "hidden"
}`}
>
<AgentCommsPanel workspaceId={workspaceId} />
</div>
</div>
@ -199,33 +216,30 @@ function MyChatPanel({ workspaceId, data }: Props) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Consume agent push messages (send_message_to_user) from global store
// Consume agent push messages (send_message_to_user) from global store.
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
// than the /a2a HTTP response — when that happens, the push is the
// authoritative "reply arrived" signal for the UI, so clear `sending`
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
// whichever path clears first wins.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
setMessages((prev) => [...prev, createMessage("agent", m.content)]);
// Dedupe in case the agent proactively pushed the same text the
// HTTP /a2a response already delivered (observed with the Hermes
// runtime, which emits both a reply body and a send_message_to_user
// push for the same content).
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
}
if (sendingFromAPIRef.current && msgs.length > 0) {
setSending(false);
sendingFromAPIRef.current = false;
}
}, [pendingAgentMsgs, workspaceId]);
// Consume A2A_RESPONSE events from global store (streaming response delivery).
// Guarded by sendingFromAPIRef to avoid duplicate messages when the
// synchronous HTTP .then() handler also fires for the same response.
const pendingA2AResponse = useCanvasStore((s) => s.agentMessages[`a2a:${workspaceId}`]);
useEffect(() => {
if (!pendingA2AResponse || pendingA2AResponse.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(`a2a:${workspaceId}`);
if (!sendingFromAPIRef.current) return; // HTTP .then() already handled this response
for (const m of msgs) {
setMessages((prev) => [...prev, createMessage("agent", m.content)]);
}
setSending(false);
sendingFromAPIRef.current = false;
}, [pendingA2AResponse, workspaceId]);
// Resolve workspace ID → name for activity display
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
@ -276,8 +290,24 @@ function MyChatPanel({ workspaceId, data }: Props) {
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
// The platform logs a successful a2a_receive once the workspace
// has fully produced its reply. That's the authoritative "done"
// signal for the spinner — clear it even if the reply hasn't
// surfaced through the store yet (it may be delivered shortly
// via pendingAgentMsgs or the HTTP .then()).
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
setSending(false);
sendingFromAPIRef.current = false;
}
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
setSending(false);
sendingFromAPIRef.current = false;
setError("Agent error (Exception) — see workspace logs for details.");
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
@ -296,11 +326,15 @@ function MyChatPanel({ workspaceId, data }: Props) {
setActivityLog((prev) => [...prev.slice(-8), `${task}`]);
}
}
// A2A_RESPONSE is handled by the store (pendingA2AResponse effect) — no duplicate here
// A2A_RESPONSE is already consumed by the store and its text is
// appended to messages via the pendingAgentMsgs effect above; we
// don't need to duplicate it here.
} catch { /* ignore */ }
};
return () => ws.close();
return () => {
closeWebSocketGracefully(ws);
};
}, [sending, workspaceId, resolveWorkspaceName]);
const sendMessage = async () => {
@ -340,7 +374,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
if (!sendingFromAPIRef.current) return;
const replyText = extractReplyText(resp);
if (replyText) {
setMessages((prev) => [...prev, createMessage("agent", replyText)]);
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", replyText)));
}
setSending(false);
sendingFromAPIRef.current = false;

View File

@ -51,17 +51,18 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
) : editing ? (
<div className="space-y-2">
<textarea
aria-label="Agent card JSON editor"
value={draft} onChange={(e) => setDraft(e.target.value)}
spellCheck={false} rows={12}
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-[10px] font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
/>
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-red-400">{error}</div>}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
<button type="button" onClick={handleSave} disabled={saving}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
<button onClick={() => setEditing(false)}
<button type="button" onClick={() => setEditing(false)}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
</div>
</div>
@ -75,7 +76,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
<div className="text-[10px] text-zinc-500">No agent card</div>
)}
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-green-400">Updated</div>}
<button onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
className="mt-2 text-[10px] text-blue-400 hover:text-blue-300">Edit Agent Card</button>
</div>
)}
@ -241,15 +242,65 @@ export function ConfigTab({ workspaceId }: Props) {
setSuccess(false);
try {
const content = rawMode ? rawDraft : toYaml(config);
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
const runtimeManagesOwnConfig = RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "");
// Only write the platform-managed config.yaml when the runtime
// actually consumes it. Hermes + external runtimes manage their
// own config file inside the container, so writing this one is a
// no-op at best and can fail with 404 if config.yaml was never
// created for this workspace.
if (!runtimeManagesOwnConfig) {
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
}
// If runtime changed, update it in the DB so restart uses the correct image
const newRuntime = rawMode
? (parseYaml(rawDraft).runtime as string || "")
: (config.runtime || "");
const oldRuntime = (parseYaml(originalYaml).runtime as string || "");
if (newRuntime && newRuntime !== oldRuntime) {
await api.patch(`/workspaces/${workspaceId}`, { runtime: newRuntime });
// DB-backed fields (name, tier, runtime, model) live on the
// workspace row, NOT in config.yaml. Fire separate PATCHes for
// the ones that actually changed — otherwise a Hermes user edits
// the form, hits Save, sees the request succeed, then watches the
// values snap back on the next reload because the workspace row
// never heard about the change.
//
// Diff against the RAW parsed YAML (or the form `config` in non-
// raw mode) rather than the DEFAULT_CONFIG-merged shape — if the
// user deleted a field in raw mode the merge would substitute the
// default (e.g. tier=1) and we'd silently PATCH that down from
// the stored value. Only fields the user actually typed get sent.
const oldParsed = parseYaml(originalYaml);
const nextSource = rawMode
? (parseYaml(rawDraft) as Record<string, unknown>)
: (config as unknown as Record<string, unknown>);
const dbPatch: Record<string, unknown> = {};
if (typeof nextSource.name === "string" && nextSource.name && nextSource.name !== oldParsed.name) {
dbPatch.name = nextSource.name;
}
if (typeof nextSource.tier === "number" && nextSource.tier !== (oldParsed.tier ?? null)) {
dbPatch.tier = nextSource.tier;
}
const oldRuntime = (oldParsed.runtime as string) || "";
if (typeof nextSource.runtime === "string" && nextSource.runtime && nextSource.runtime !== oldRuntime) {
dbPatch.runtime = nextSource.runtime;
}
if (Object.keys(dbPatch).length > 0) {
await api.patch(`/workspaces/${workspaceId}`, dbPatch);
}
// Model has its own endpoint (separate from the general workspace
// PATCH) because the runtime may need to validate it against the
// template's supported models list. A model rejection is a
// partial-save state — we report it as a user-visible warning
// rather than lying "Saved" and letting the user discover the
// revert on next reload.
const oldModel = (oldParsed.model as string) || "";
let modelSaveError: string | null = null;
if (
typeof nextSource.model === "string" &&
nextSource.model &&
nextSource.model !== oldModel
) {
try {
await api.put(`/workspaces/${workspaceId}/model`, { model: nextSource.model });
} catch (e) {
modelSaveError = e instanceof Error ? e.message : "Model update was rejected";
}
}
setOriginalYaml(content);
@ -264,9 +315,16 @@ export function ConfigTab({ workspaceId }: Props) {
} else {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
}
setSuccess(true);
clearTimeout(successTimerRef.current);
successTimerRef.current = setTimeout(() => setSuccess(false), 2000);
if (modelSaveError) {
// Partial-save UX: surface the model rejection instead of
// showing "Saved" — the user would otherwise watch the model
// field revert on next reload with no explanation.
setError(`Other fields saved, but model update failed: ${modelSaveError}`);
} else {
setSuccess(true);
clearTimeout(successTimerRef.current);
successTimerRef.current = setTimeout(() => setSuccess(false), 2000);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
@ -315,6 +373,7 @@ export function ConfigTab({ workspaceId }: Props) {
{rawMode ? (
<div className="flex-1 p-3">
<textarea
aria-label="Raw YAML editor"
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
spellCheck={false}
@ -432,13 +491,19 @@ export function ConfigTab({ workspaceId }: Props) {
label={
currentModelSpec?.required_env?.length &&
arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env)
? "Required Env Vars (from template)"
: "Required Env Vars"
? "Required Env Var Names (from template)"
: "Required Env Var Names"
}
values={config.runtime_config?.required_env ?? []}
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN"
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
/>
<p className="text-[10px] text-zinc-500 mt-1">
This declares which env var <em>names</em> the workspace needs.
Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at
runtime.
</p>
{currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
@ -545,7 +610,10 @@ export function ConfigTab({ workspaceId }: Props) {
</div>
</Section>
<SecretsSection workspaceId={workspaceId} />
<SecretsSection
workspaceId={workspaceId}
requiredEnv={config.runtime_config?.required_env}
/>
<AgentCardSection workspaceId={workspaceId} />
</div>
@ -567,6 +635,7 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="p-3 border-t border-zinc-800 flex gap-2">
<button
type="button"
onClick={() => handleSave(true)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-30 transition-colors"
@ -574,6 +643,7 @@ export function ConfigTab({ workspaceId }: Props) {
{saving ? "Restarting..." : "Save & Restart"}
</button>
<button
type="button"
onClick={() => handleSave(false)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 disabled:opacity-30 transition-colors"
@ -581,6 +651,7 @@ export function ConfigTab({ workspaceId }: Props) {
Save
</button>
<button
type="button"
onClick={loadConfig}
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 ml-auto"
>

View File

@ -159,6 +159,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
)}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-50"
@ -166,6 +167,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => {
setEditing(false);
setSaveError(null);
@ -199,6 +201,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
</div>
)}
<button
type="button"
onClick={handleRestart}
disabled={restarting}
className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50"
@ -208,6 +211,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
</div>
)}
<button
type="button"
onClick={() => setEditing(true)}
className="mt-2 px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
>
@ -234,6 +238,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<p className="text-xs text-zinc-500">No error detail recorded.</p>
)}
<button
type="button"
onClick={() => setConsoleOpen(true)}
className="mt-2 px-3 py-1 bg-zinc-800 hover:bg-zinc-700 text-xs rounded text-zinc-300 border border-zinc-700"
>
@ -279,6 +284,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{peers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => selectNode(p.id)}
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-zinc-800 text-left"
>
@ -310,12 +316,14 @@ export function DetailsTab({ workspaceId, data }: Props) {
</h3>
<div className="flex gap-2">
<button
type="button"
onClick={handleDelete}
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
>
Confirm Delete
</button>
<button
type="button"
onClick={() => {
setConfirmDelete(false);
setDeleteError(null);
@ -330,6 +338,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
</div>
) : (
<button
type="button"
ref={deleteButtonRef}
onClick={() => setConfirmDelete(true)}
className="px-3 py-1 bg-zinc-800 hover:bg-red-900 border border-zinc-700 hover:border-red-700 text-xs rounded text-zinc-400 hover:text-red-400 transition-colors"

View File

@ -165,8 +165,8 @@ export function FilesTab({ workspaceId }: Props) {
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p className="text-xs text-red-300">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<div className="flex gap-2">
<button onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
<button onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
</div>
</div>
)}
@ -179,8 +179,8 @@ export function FilesTab({ workspaceId }: Props) {
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p className="text-xs text-amber-300">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div className="flex gap-2">
<button onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
<button onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
</div>
</div>
)}

View File

@ -135,12 +135,14 @@ export function MemoryTab({ workspaceId }: Props) {
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
>
@ -173,6 +175,7 @@ export function MemoryTab({ workspaceId }: Props) {
</p>
</div>
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
>
@ -207,18 +210,21 @@ export function MemoryTab({ workspaceId }: Props) {
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
>
@ -254,12 +260,14 @@ export function MemoryTab({ workspaceId }: Props) {
{error && <div role="alert" className="text-xs text-red-400">{error}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white"
>
Save
</button>
<button
type="button"
onClick={() => {
setShowAdd(false);
setError(null);
@ -280,6 +288,7 @@ export function MemoryTab({ workspaceId }: Props) {
{entries.map((entry) => (
<div key={entry.key} className="bg-zinc-800 rounded border border-zinc-700">
<button
type="button"
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left"
aria-expanded={expanded === entry.key}
@ -307,6 +316,7 @@ export function MemoryTab({ workspaceId }: Props) {
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-red-400 hover:text-red-300"
>
@ -328,6 +338,7 @@ export function MemoryTab({ workspaceId }: Props) {
</p>
</div>
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
>

View File

@ -68,22 +68,32 @@ export function SkillsTab({ data }: Props) {
const loadInstalled = useCallback(async () => {
try {
const result = await api.get<PluginInfo[]>(`/workspaces/${workspaceId}/plugins`);
if (mountedRef.current) setInstalled(result);
} catch { /* ignore */ }
if (mountedRef.current) setInstalled(Array.isArray(result) ? result : []);
} catch (e) {
console.warn("SkillsTab: installed plugins load failed", e);
}
}, [workspaceId]);
const loadRegistry = useCallback(async () => {
try {
const result = await api.get<PluginInfo[]>("/plugins");
if (mountedRef.current) setRegistry(result);
} catch { /* ignore */ }
if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
} catch (e) {
// Registry is the AVAILABLE PLUGINS list. Silent failure here
// left the user seeing "No plugins in registry" with no clue
// it was a fetch error — log it so devtools shows the cause.
console.warn("SkillsTab: registry load failed", e);
}
}, []);
const loadSourceSchemes = useCallback(async () => {
try {
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
} catch { /* ignore — falls back to "local only" UX */ }
} catch (e) {
console.warn("SkillsTab: plugin sources load failed", e);
// Falls back to "local only" UX — non-fatal.
}
}, []);
useEffect(() => {

View File

@ -0,0 +1,181 @@
// @vitest-environment jsdom
//
// Regression tests for ConfigTab hermes-workspace UX (#1894 + #1900).
//
// All four bugs this suite pins hit the same workspace on 2026-04-23:
// a hermes-runtime workspace whose Config tab showed "LangGraph
// (default)" in the runtime dropdown, an empty Model field, and a
// scary red "No config.yaml found" banner. Clicking Save would
// silently PATCH runtime back to LangGraph, breaking the workspace.
//
// Each test pins one invariant. If any fails, the bug is back.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
// ── API mock ──────────────────────────────────────────────────────────
// ConfigTab calls three endpoints on load:
// 1. GET /workspaces/:id — workspace metadata (runtime)
// 2. GET /workspaces/:id/model — model
// 3. GET /workspaces/:id/files/config.yaml — template-managed config (may 404)
// And POST /templates for the runtime dropdown options.
//
// Each test wires the mock to return the shape that matches the scenario
// it's pinning. Unhandled URLs default to rejecting so the test fails loud
// if ConfigTab queries something unexpected.
const apiGet = vi.fn();
const apiPatch = vi.fn();
const apiPut = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string) => apiGet(path),
patch: (path: string, body: unknown) => apiPatch(path, body),
put: (path: string, body: unknown) => apiPut(path, body),
post: vi.fn(),
del: vi.fn(),
},
}));
// Zustand store used by Save → restart. Not exercised in these tests.
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }),
{ getState: () => ({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }) },
),
}));
// AgentCardSection fetches its own data — stub to avoid noise.
vi.mock("../AgentCardSection", () => ({
AgentCardSection: () => <div data-testid="agent-card-stub" />,
}));
import { ConfigTab } from "../ConfigTab";
// helper — wire the api.get mock for one scenario
function wireApi(opts: {
workspaceRuntime?: string;
workspaceModel?: string;
configYamlContent?: string | null; // null = 404
templates?: Array<{ id: string; name?: string; runtime?: string; models?: unknown[] }>;
}) {
apiGet.mockImplementation((path: string) => {
if (path === `/workspaces/ws-test`) {
return Promise.resolve({ runtime: opts.workspaceRuntime ?? "" });
}
if (path === `/workspaces/ws-test/model`) {
return Promise.resolve({ model: opts.workspaceModel ?? "" });
}
if (path === `/workspaces/ws-test/files/config.yaml`) {
if (opts.configYamlContent === null) {
return Promise.reject(new Error("not found"));
}
return Promise.resolve({ content: opts.configYamlContent ?? "" });
}
if (path === "/templates") {
return Promise.resolve(opts.templates ?? []);
}
return Promise.reject(new Error(`unmocked api.get: ${path}`));
});
}
beforeEach(() => {
apiGet.mockReset();
apiPatch.mockReset();
apiPut.mockReset();
});
describe("ConfigTab — hermes workspace", () => {
it("loads runtime from workspace metadata when config.yaml is missing (#1894 bug 1)", async () => {
// This is the hermes case: no platform config.yaml, so the form must
// fall back to GET /workspaces/:id's runtime field. Before the fix, the
// runtime dropdown showed "LangGraph (default)" because the fallback
// didn't exist.
wireApi({
workspaceRuntime: "hermes",
workspaceModel: "openai/gpt-4o",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});
render(<ConfigTab workspaceId="ws-test" />);
// Wait for loads
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
expect((select as HTMLSelectElement).value).toBe("hermes");
});
it("does NOT show 'No config.yaml found' error for hermes (#1894 bug 3)", async () => {
// Hermes manages its own config at ~/.hermes/config.yaml on the
// workspace host — the platform config.yaml NOT existing is expected,
// not an error. Showing a red error banner misleads the user.
wireApi({
workspaceRuntime: "hermes",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});
render(<ConfigTab workspaceId="ws-test" />);
await waitFor(() => {
const node = screen.queryByText(/No config\.yaml found/i);
// Assert the red error is absent; a gray info banner with the same
// phrase would also fail this (which is what we want — we don't
// want any "no config.yaml" phrasing on hermes at all).
expect(node).toBeNull();
});
});
it("shows hermes-specific info banner pointing to Terminal tab (#1894)", async () => {
wireApi({
workspaceRuntime: "hermes",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});
render(<ConfigTab workspaceId="ws-test" />);
await waitFor(() => {
expect(screen.getByText(/Hermes manages its own config/i)).toBeTruthy();
});
});
it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
// Regression guard the other way — the gray info banner is hermes-
// specific. A langgraph workspace with no config.yaml SHOULD still
// see the red error so the user knows to provide a template config.
wireApi({
workspaceRuntime: "",
configYamlContent: null,
templates: [],
});
render(<ConfigTab workspaceId="ws-test" />);
await waitFor(() => {
expect(screen.getByText(/No config\.yaml found/i)).toBeTruthy();
});
});
});
describe("ConfigTab — config.yaml on disk", () => {
it("config.yaml runtime/model wins when present, workspace metadata is fallback", async () => {
// If the workspace DB has runtime=langgraph but config.yaml declares
// runtime: crewai, the form should show crewai (config.yaml wins).
// Prevents silent runtime drift across reads.
wireApi({
workspaceRuntime: "langgraph", // DB
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
templates: [
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
],
});
render(<ConfigTab workspaceId="ws-test" />);
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
expect((select as HTMLSelectElement).value).toBe("crewai");
});
});

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { WS_URL } from "@/store/socket";
import { closeWebSocketGracefully } from "@/lib/ws-close";
import { extractResponseText, extractRequestText } from "./message-parser";
interface ActivityEntry {
@ -122,7 +123,9 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
}
} catch { /* ignore */ }
};
return () => ws.close();
return () => {
closeWebSocketGracefully(ws);
};
}, [workspaceId]);
useEffect(() => {

View File

@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { appendMessageDeduped, createMessage, type ChatMessage } from "../types";
// Unit tests for appendMessageDeduped — the helper that collapses the
// race between the HTTP /a2a .then() handler, the A2A_RESPONSE WS event,
// and the send_message_to_user push. All three paths can deliver the
// same agent reply; without dedupe the user sees 2-3 identical bubbles
// with identical timestamps.
describe("appendMessageDeduped", () => {
beforeEach(() => {
vi.useFakeTimers();
// Pin Date.now so "recently added" windows are deterministic across
// the dedupe + Date.parse calls inside the helper.
vi.setSystemTime(new Date("2026-04-23T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("appends a new message when the history is empty", () => {
const msg = createMessage("agent", "hello");
const next = appendMessageDeduped([], msg);
expect(next).toHaveLength(1);
expect(next[0]).toBe(msg);
});
it("appends when content differs from the recent tail", () => {
const first = createMessage("agent", "hello");
vi.advanceTimersByTime(100);
const second = createMessage("agent", "world");
const next = appendMessageDeduped([first], second);
expect(next).toHaveLength(2);
});
it("skips a duplicate (same role+content) within the window", () => {
const first = createMessage("agent", "Hey! How can I help you today?");
vi.advanceTimersByTime(500); // well inside the 3s window
const dup = createMessage("agent", "Hey! How can I help you today?");
const next = appendMessageDeduped([first], dup);
expect(next).toHaveLength(1);
// The array is returned unchanged — not a new reference.
expect(next[0]).toBe(first);
});
it("does NOT dedupe across different roles even if content matches", () => {
// Agent echoing the user's "hi" is a legitimate two-bubble case.
const user = createMessage("user", "hi");
vi.advanceTimersByTime(100);
const agent = createMessage("agent", "hi");
const next = appendMessageDeduped([user], agent);
expect(next).toHaveLength(2);
});
it("does NOT dedupe once the window has elapsed", () => {
// A user legitimately sending "hi" a few seconds apart must render
// both bubbles. Default window is 3000 ms.
const first = createMessage("user", "hi");
vi.advanceTimersByTime(4000);
const repeat = createMessage("user", "hi");
const next = appendMessageDeduped([first], repeat);
expect(next).toHaveLength(2);
});
it("only checks the tail's content, not the entire history", () => {
// Same (role, content) appearing earlier in the conversation but
// outside the dedupe window is not a duplicate.
const old = createMessage("agent", "hi");
vi.advanceTimersByTime(10_000);
const newer = createMessage("agent", "hi");
const next = appendMessageDeduped([old], newer);
expect(next).toHaveLength(2);
});
it("handles malformed timestamps without throwing", () => {
// Defense: a history entry with a bogus timestamp shouldn't nuke
// the append path. The helper should just treat that entry as
// "too old to dedupe against" and append the new message.
const garbled: ChatMessage = {
id: "x",
role: "agent",
content: "hi",
timestamp: "not-a-real-timestamp",
};
const fresh = createMessage("agent", "hi");
expect(() => appendMessageDeduped([garbled], fresh)).not.toThrow();
const next = appendMessageDeduped([garbled], fresh);
expect(next).toHaveLength(2);
});
it("accepts a custom dedupe window", () => {
const first = createMessage("agent", "hello");
vi.advanceTimersByTime(500);
// Tight 100 ms window — the 500 ms-old first message falls outside.
const dup = createMessage("agent", "hello");
const next = appendMessageDeduped([first], dup, 100);
expect(next).toHaveLength(2);
});
});

View File

@ -1,2 +1,2 @@
export { type ChatMessage, createMessage } from "./types";
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";

View File

@ -8,3 +8,28 @@ export interface ChatMessage {
export function createMessage(role: ChatMessage["role"], content: string): ChatMessage {
return { id: crypto.randomUUID(), role, content, timestamp: new Date().toISOString() };
}
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
// already contains the same (role, content) from within
// dedupeWindowMs. Collapses the case where two delivery paths race to
// render the same agent reply — e.g. the HTTP .then() handler for
// POST /a2a AND a `send_message_to_user` WebSocket push from the
// runtime, both carrying the same text. Without this guard the user
// sees two or three identical bubbles with identical timestamps.
//
// Why a time-windowed check instead of dedupe-by-id: the three delivery
// paths (HTTP response, WS A2A_RESPONSE, WS send_message_to_user) each
// mint a fresh `createMessage` with a random UUID client-side — there's
// no stable end-to-end message id yet. Content+role+time is the
// pragmatic identity. The window is short (3s) so genuine repeat
// messages ("hi", "hi") from a real user/agent still render.
export function appendMessageDeduped(prev: ChatMessage[], msg: ChatMessage, dedupeWindowMs = 3000): ChatMessage[] {
const cutoff = Date.now() - dedupeWindowMs;
const alreadyThere = prev.some((m) => {
if (m.role !== msg.role || m.content !== msg.content) return false;
const t = Date.parse(m.timestamp);
return !Number.isNaN(t) && t >= cutoff;
});
if (alreadyThere) return prev;
return [...prev, msg];
}

View File

@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { SecretsSection } from "../secrets-section";
// Tests for SecretsSection — locks in the fix that the secret-slot
// list is driven by the workspace's `runtime_config.required_env`
// instead of a hardcoded COMMON_KEYS list.
//
// Before the fix the component always rendered Anthropic / OpenAI /
// Google / SERP / Model Override slots regardless of template. For a
// Hermes workspace that declares MINIMAX_API_KEY that meant the user
// saw five irrelevant slots and no slot for the key they actually
// needed.
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn().mockResolvedValue([]),
put: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
del: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/canvas-actions", () => ({
markAllWorkspacesNeedRestart: vi.fn(),
}));
// The Section wrapper is collapsible with `defaultOpen={false}`. For
// tests we want the content visible without a click — replace the
// wrapper with a passthrough that always renders children.
vi.mock("../form-inputs", async () => {
const actual = await vi.importActual<typeof import("../form-inputs")>("../form-inputs");
return {
...actual,
Section: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
};
});
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("SecretsSection — template-driven slots", () => {
it("renders exactly the slots the template declares in required_env", async () => {
render(
<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />,
);
await waitFor(() => {
expect(screen.getByText("MINIMAX_API_KEY")).toBeTruthy();
});
// Hardcoded slots that were there before this fix must NOT appear
// when the template doesn't ask for them.
expect(screen.queryByText("ANTHROPIC_API_KEY")).toBeNull();
expect(screen.queryByText("OPENAI_API_KEY")).toBeNull();
expect(screen.queryByText("GOOGLE_API_KEY")).toBeNull();
expect(screen.queryByText("SERP_API_KEY")).toBeNull();
});
it("uses the friendly label from KNOWN_LABELS for a well-known name", async () => {
render(
<SecretsSection workspaceId="ws-1" requiredEnv={["ANTHROPIC_API_KEY"]} />,
);
await waitFor(() => {
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
});
});
it("humanises an unknown env var name into a readable label", async () => {
render(
<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />,
);
await waitFor(() => {
// "Minimax API Key" — "API" acronym preserved, "Minimax" title-cased.
expect(screen.getByText("Minimax API Key")).toBeTruthy();
});
});
it("preserves API / URL acronyms when humanising", async () => {
render(
<SecretsSection
workspaceId="ws-1"
requiredEnv={["ZHIPU_API_KEY", "CUSTOM_MODEL_URL"]}
/>,
);
await waitFor(() => {
expect(screen.getByText("Zhipu API Key")).toBeTruthy();
expect(screen.getByText("Custom Model URL")).toBeTruthy();
});
});
it("deduplicates repeated entries in required_env", async () => {
render(
<SecretsSection
workspaceId="ws-1"
requiredEnv={["MINIMAX_API_KEY", "MINIMAX_API_KEY", "OPENAI_API_KEY"]}
/>,
);
await waitFor(() => {
// Only one row for the repeated name.
const matches = screen.getAllByText("MINIMAX_API_KEY");
expect(matches).toHaveLength(1);
expect(screen.getByText("OpenAI API Key")).toBeTruthy();
});
});
it("falls back to the legacy common-keys list when required_env is missing", async () => {
// Backward compat: old workspaces without a template-set
// required_env still see Anthropic/OpenAI/Google/SERP slots.
render(<SecretsSection workspaceId="ws-1" />);
await waitFor(() => {
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
});
expect(screen.getByText("OpenAI API Key")).toBeTruthy();
expect(screen.getByText("Google AI API Key")).toBeTruthy();
});
it("falls back to the legacy common-keys list when required_env is empty", async () => {
render(<SecretsSection workspaceId="ws-1" requiredEnv={[]} />);
await waitFor(() => {
expect(screen.getByText("Anthropic API Key")).toBeTruthy();
});
});
it("does not fall back when required_env has at least one entry", async () => {
// Single-entry required_env must NOT spill legacy slots into the UI.
render(<SecretsSection workspaceId="ws-1" requiredEnv={["MINIMAX_API_KEY"]} />);
await waitFor(() => {
expect(screen.getByText("MINIMAX_API_KEY")).toBeTruthy();
});
expect(screen.queryByText("Anthropic API Key")).toBeNull();
expect(screen.queryByText("OpenAI API Key")).toBeNull();
});
});

View File

@ -49,14 +49,17 @@ export const DEFAULT_CONFIG: ConfigData = {
};
export function TextInput({ label, value, onChange, placeholder, mono }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div>
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={label}
className={`w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 ${mono ? "font-mono" : ""}`}
/>
</div>
@ -64,15 +67,18 @@ export function TextInput({ label, value, onChange, placeholder, mono }: { label
}
export function NumberInput({ label, value, onChange, min, max }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div>
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<input
id={id}
type="number"
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
min={min}
max={max}
aria-label={label}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
/>
</div>
@ -89,10 +95,11 @@ export function Toggle({ label, checked, onChange }: { label: string; checked: b
}
export function TagList({ label, values, onChange, placeholder }: { label: string; values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
const id = `taglist-${label.toLowerCase().replace(/\s+/g, "-")}`;
const [input, setInput] = useState("");
return (
<div>
<label className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<label htmlFor={id} className="text-[10px] text-zinc-500 block mb-1">{label}</label>
<div className="flex flex-wrap gap-1 mb-1">
{values.map((v, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 font-mono">
@ -102,6 +109,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
))}
</div>
<input
id={id}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
@ -112,6 +120,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
}
}}
placeholder={placeholder || "Type and press Enter"}
aria-label={label}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-[10px] text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
/>
</div>

View File

@ -13,14 +13,59 @@ interface SecretEntry {
scope?: "global" | "workspace";
}
const COMMON_KEYS = [
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API Key" },
{ key: "OPENAI_API_KEY", label: "OpenAI API Key" },
{ key: "GOOGLE_API_KEY", label: "Google AI API Key" },
{ key: "SERP_API_KEY", label: "SERP API Key" },
{ key: "MODEL_PROVIDER", label: "Model Override (e.g. anthropic:claude-sonnet-4-6)" },
// Human-friendly labels for well-known env-var names. Used to render
// familiar copy ("Anthropic API Key") instead of the raw variable name
// when the template declares one of these. Unknown names (e.g.
// MINIMAX_API_KEY, ZHIPU_API_KEY) fall through to humanizeKeyName below
// — a generic "Minimax API Key" label is better than no label at all.
//
// SECRETS_WHEN_NO_TEMPLATE is the fallback set shown only when a
// workspace's template doesn't declare any required_env (legacy /
// bare-runtime case). In the normal flow the list is driven by
// runtime_config.required_env passed in from the Config tab.
const KNOWN_LABELS: Record<string, string> = {
ANTHROPIC_API_KEY: "Anthropic API Key",
OPENAI_API_KEY: "OpenAI API Key",
GOOGLE_API_KEY: "Google AI API Key",
SERP_API_KEY: "SERP API Key",
OPENROUTER_API_KEY: "OpenRouter API Key",
HERMES_API_KEY: "Hermes API Key (Nous Research)",
GROQ_API_KEY: "Groq API Key",
CEREBRAS_API_KEY: "Cerebras API Key",
MINIMAX_API_KEY: "Minimax API Key",
MODEL_PROVIDER: "Model Override (e.g. anthropic:claude-sonnet-4-6)",
};
const SECRETS_WHEN_NO_TEMPLATE = [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"SERP_API_KEY",
"MODEL_PROVIDER",
];
// humanizeKeyName converts SCREAMING_SNAKE_CASE into "Title Case Words"
// so templates that declare uncommon env var names still get a readable
// label. "MINIMAX_API_KEY" → "Minimax API Key". Preserves "API" / "URL"
// acronyms via the normalize step.
function humanizeKeyName(key: string): string {
const words = key.toLowerCase().split("_").filter(Boolean);
return words
.map((w) => {
const upper = w.toUpperCase();
// Keep common acronyms upper-case.
if (["API", "URL", "URI", "ID", "SDK", "MCP", "LLM", "AI"].includes(upper)) {
return upper;
}
return w.charAt(0).toUpperCase() + w.slice(1);
})
.join(" ");
}
function labelForKey(key: string): string {
return KNOWN_LABELS[key] ?? humanizeKeyName(key);
}
function ScopeBadge({ scope }: { scope: "global" | "workspace" | "override" }) {
if (scope === "global") {
return <span className="text-[8px] text-amber-400 bg-amber-900/30 px-1.5 py-0.5 rounded" title="Inherited from global secrets">Global</span>;
@ -147,7 +192,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
);
}
export function SecretsSection({ workspaceId }: { workspaceId: string }) {
export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: string; requiredEnv?: string[] }) {
const [mergedSecrets, setMergedSecrets] = useState<SecretEntry[]>([]);
const [globalSecrets, setGlobalSecrets] = useState<SecretEntry[]>([]);
const [loading, setLoading] = useState(true);
@ -218,9 +263,27 @@ export function SecretsSection({ workspaceId }: { workspaceId: string }) {
// For global view: use global secrets only
const activeSecrets = globalMode ? globalSecrets : mergedSecrets;
// Split into common keys and custom keys
const commonKeySet = new Set(COMMON_KEYS.map((c) => c.key));
const customSecrets = activeSecrets.filter((s) => !commonKeySet.has(s.key));
// Template-driven slots: render one labelled row per env var the
// template declares. Falls back to a legacy common-keys list when
// the template has nothing (older workspaces / bare runtimes) so
// the Secrets section is never empty.
const templateKeys = (requiredEnv && requiredEnv.length > 0)
? requiredEnv
: SECRETS_WHEN_NO_TEMPLATE;
// Deduplicate while preserving order — a template that lists the
// same key twice shouldn't render two rows.
const seen = new Set<string>();
const slotKeys = templateKeys.filter((k) => {
if (seen.has(k)) return false;
seen.add(k);
return true;
});
// Split into template-slot keys and user-added custom keys so the
// latter still surface even when not declared by the template.
const slotKeySet = new Set(slotKeys);
const customSecrets = activeSecrets.filter((s) => !slotKeySet.has(s.key));
return (
<Section title="Secrets & API Keys" defaultOpen={false}>
@ -256,15 +319,16 @@ export function SecretsSection({ workspaceId }: { workspaceId: string }) {
</div>
)}
{/* Common keys */}
{COMMON_KEYS.map(({ key, label }) => {
{/* Template-declared slots one labelled row per env var
the workspace actually needs. Driven by runtime_config.required_env. */}
{slotKeys.map((key) => {
const entry = globalMode
? globalSecrets.find((s) => s.key === key)
: mergedByKey.get(key);
const isSet = !!entry?.has_value;
const scope = globalMode ? undefined : (entry ? getScope(entry) : undefined);
return (
<SecretRow key={key} label={label} secretKey={key}
<SecretRow key={key} label={labelForKey(key)} secretKey={key}
isSet={isSet}
scope={scope}
globalMode={globalMode}

View File

@ -30,7 +30,7 @@ export function RevealToggle({
function EyeIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
@ -39,7 +39,7 @@ function EyeIcon() {
function EyeOffIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />

View File

@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Dedicated file for the 401 → login-redirect tests because they need
// `window.location.hostname` (jsdom), while the rest of api.test.ts
// runs happily in node. Splitting keeps the node tests fast.
// ---------------------------------------------------------------------------
// 401 handling — gated on SaaS-tenant hostname
// ---------------------------------------------------------------------------
//
// Before fix/quickstart-bugless, any 401 from any endpoint triggered
// `redirectToLogin()`, navigating to `/cp/auth/login`. That route
// exists only on SaaS (mounted by cp_proxy when CP_UPSTREAM_URL is
// set). On localhost / self-hosted / Vercel preview it 404s, so the
// user lands on a broken login page instead of seeing the actual error.
//
// These tests lock in:
// - SaaS tenant hostname (*.moleculesai.app) → 401 still redirects.
// - non-SaaS hostname (localhost, LAN IP, apex) → 401 throws, no
// redirect, so the caller renders a real error affordance.
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function mockFailure(status: number, text: string) {
mockFetch.mockResolvedValueOnce({
ok: false,
status,
json: () => Promise.reject(new Error("no json")),
text: () => Promise.resolve(text),
} as unknown as Response);
}
function setHostname(host: string) {
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, hostname: host },
});
}
describe("api 401 handling", () => {
let redirectSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
redirectSpy = vi.fn();
vi.doMock("../auth", () => ({
redirectToLogin: redirectSpy,
// Stub siblings so any other import of ../auth in the chain
// (AuthGate, TermsGate, etc.) still resolves.
fetchSession: vi.fn().mockResolvedValue(null),
}));
});
afterEach(() => {
vi.doUnmock("../auth");
vi.resetModules();
});
it("redirects to login on SaaS tenant hostname", async () => {
setHostname("acme.moleculesai.app");
mockFailure(401, '{"error":"admin auth required"}');
const { api } = await import("../api");
await expect(api.get("/workspaces")).rejects.toThrow(/Session expired/);
expect(redirectSpy).toHaveBeenCalledWith("sign-in");
});
it("does NOT redirect on localhost — throws a real error instead", async () => {
setHostname("localhost");
mockFailure(401, '{"error":"admin auth required"}');
const { api } = await import("../api");
await expect(api.get("/workspaces")).rejects.toThrow(/401/);
expect(redirectSpy).not.toHaveBeenCalled();
});
it("does NOT redirect on a LAN hostname", async () => {
setHostname("192.168.1.74");
mockFailure(401, '{"error":"missing workspace auth token"}');
const { api } = await import("../api");
await expect(api.get("/workspaces/abc/activity")).rejects.toThrow(/401/);
expect(redirectSpy).not.toHaveBeenCalled();
});
it("does NOT redirect on reserved subdomains (app.moleculesai.app)", async () => {
// `app` is in reservedSubdomains — getTenantSlug returns "" there.
// Users landing on app.moleculesai.app (pre-tenant-selection) must
// see the real 401 error rather than loop on login.
setHostname("app.moleculesai.app");
mockFailure(401, '{"error":"admin auth required"}');
const { api } = await import("../api");
await expect(api.get("/workspaces")).rejects.toThrow(/401/);
expect(redirectSpy).not.toHaveBeenCalled();
});
});

View File

@ -55,8 +55,6 @@ describe("redirectToLogin", () => {
},
});
redirectToLogin("sign-in");
// href now holds the redirect target. encodeURIComponent(href) must
// appear in the query.
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/login");
expect((window.location as unknown as { href: string }).href).toContain(
encodeURIComponent(href),
@ -76,4 +74,39 @@ describe("redirectToLogin", () => {
redirectToLogin("sign-up");
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup");
});
// Regression: AuthGate + redirectToLogin mutual recursion on /cp/auth/login
// caused double-encoded return_to that grew until the URL exceeded 431.
// Guard: redirectToLogin must NOT set window.location when already on an
// auth path, otherwise each call adds another encoding layer.
it("does NOT set window.location when already on /cp/auth/login (redirect loop guard)", () => {
const loginHref = "https://app.moleculesai.app/cp/auth/login?return_to=https%3A%2F%2Facme.moleculesai.app%2Fdashboard";
Object.defineProperty(window, "location", {
writable: true,
value: {
href: loginHref,
pathname: "/cp/auth/login",
hostname: "app.moleculesai.app",
protocol: "https:",
},
});
redirectToLogin("sign-in");
// href must be unchanged — any mutation means the guard is missing
expect((window.location as unknown as { href: string }).href).toBe(loginHref);
});
it("does NOT set window.location when already on /cp/auth/signup (redirect loop guard)", () => {
const signupHref = "https://app.moleculesai.app/cp/auth/signup";
Object.defineProperty(window, "location", {
writable: true,
value: {
href: signupHref,
pathname: "/cp/auth/signup",
hostname: "app.moleculesai.app",
protocol: "https:",
},
});
redirectToLogin("sign-up");
expect((window.location as unknown as { href: string }).href).toBe(signupHref);
});
});

View File

@ -1,121 +1,148 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock fetch globally before importing the module
global.fetch = vi.fn();
import {
getRequiredKeys,
findMissingKeys,
getKeyLabel,
checkDeploySecrets,
RUNTIME_REQUIRED_KEYS,
KEY_LABELS,
providersFromTemplate,
findSatisfiedProvider,
getKeyLabel,
getProviderLabel,
type TemplateLike,
type ModelSpec,
} from "../deploy-preflight";
beforeEach(() => {
vi.clearAllMocks();
});
/* ---------- getRequiredKeys ---------- */
// -----------------------------------------------------------------------------
// Fixtures mirroring what the Go /templates endpoint returns from each
// template repo's config.yaml. Keep these minimal — we only need the
// fields the preflight reads.
// -----------------------------------------------------------------------------
describe("getRequiredKeys", () => {
it("returns OPENAI_API_KEY for langgraph", () => {
expect(getRequiredKeys("langgraph")).toEqual(["OPENAI_API_KEY"]);
const hermesModels: ModelSpec[] = [
{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] },
{ id: "nousresearch/hermes-3-405b", name: "Hermes 3 405B", required_env: ["OPENROUTER_API_KEY"] },
{ id: "anthropic/claude-opus", name: "Claude Opus", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "openai/gpt-5", name: "GPT-5 via OpenRouter", required_env: ["OPENROUTER_API_KEY"] },
{ id: "custom/local", name: "Local endpoint", required_env: [] },
];
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
const LANGGRAPH: TemplateLike = {
runtime: "langgraph",
required_env: ["OPENAI_API_KEY"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
// providersFromTemplate
// -----------------------------------------------------------------------------
describe("providersFromTemplate", () => {
it("groups hermes models by unique required_env tuples", () => {
const providers = providersFromTemplate(HERMES);
// Three distinct tuples: HERMES_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_API_KEY.
// The `custom/local` entry has required_env: [] and must be skipped.
expect(providers.map((p) => p.id)).toEqual([
"HERMES_API_KEY",
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
]);
});
it("returns ANTHROPIC_API_KEY for claude-code", () => {
expect(getRequiredKeys("claude-code")).toEqual(["ANTHROPIC_API_KEY"]);
it("decorates labels with model counts when a provider serves multiple models", () => {
const providers = providersFromTemplate(HERMES);
const openrouter = providers.find((p) => p.id === "OPENROUTER_API_KEY");
expect(openrouter?.label).toMatch(/\(2 models\)/);
const hermes = providers.find((p) => p.id === "HERMES_API_KEY");
expect(hermes?.label).not.toMatch(/\(\d+ models\)/);
});
it("returns OPENAI_API_KEY for crewai", () => {
expect(getRequiredKeys("crewai")).toEqual(["OPENAI_API_KEY"]);
it("preserves insertion order so the template author controls defaults", () => {
const providers = providersFromTemplate(HERMES);
expect(providers[0].id).toBe("HERMES_API_KEY");
});
it("returns OPENAI_API_KEY for autogen", () => {
expect(getRequiredKeys("autogen")).toEqual(["OPENAI_API_KEY"]);
it("falls back to top-level required_env when no models[] are declared", () => {
const providers = providersFromTemplate(LANGGRAPH);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
});
it("returns OPENAI_API_KEY for openclaw", () => {
expect(getRequiredKeys("openclaw")).toEqual(["OPENAI_API_KEY"]);
it("returns [] for templates declaring no env requirements", () => {
expect(providersFromTemplate(UNKNOWN)).toEqual([]);
});
it("returns OPENAI_API_KEY for deepagents", () => {
expect(getRequiredKeys("deepagents")).toEqual(["OPENAI_API_KEY"]);
});
it("returns empty array for unknown runtimes", () => {
expect(getRequiredKeys("unknown-runtime")).toEqual([]);
expect(getRequiredKeys("")).toEqual([]);
it("supports multi-env providers (AND-semantics inside one option)", () => {
const tmpl: TemplateLike = {
runtime: "agent",
models: [
{ id: "m", required_env: ["OPENAI_API_KEY", "SERPER_API_KEY"] },
],
};
const providers = providersFromTemplate(tmpl);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY", "SERPER_API_KEY"]);
});
});
/* ---------- findMissingKeys ---------- */
// -----------------------------------------------------------------------------
// findSatisfiedProvider
// -----------------------------------------------------------------------------
describe("findMissingKeys", () => {
it("returns empty array when all keys are configured", () => {
const configured = new Set(["OPENAI_API_KEY", "OTHER_KEY"]);
expect(findMissingKeys("langgraph", configured)).toEqual([]);
describe("findSatisfiedProvider", () => {
it("returns the first provider whose envVars are all configured", () => {
const providers = providersFromTemplate(HERMES);
const satisfied = findSatisfiedProvider(
providers,
new Set(["ANTHROPIC_API_KEY"]),
);
expect(satisfied?.id).toBe("ANTHROPIC_API_KEY");
});
it("returns missing keys when not configured", () => {
const configured = new Set(["OTHER_KEY"]);
expect(findMissingKeys("langgraph", configured)).toEqual(["OPENAI_API_KEY"]);
it("returns null when no provider is fully configured", () => {
const providers = providersFromTemplate(HERMES);
expect(findSatisfiedProvider(providers, new Set())).toBeNull();
});
it("returns empty array for runtime with no required keys", () => {
const configured = new Set<string>();
expect(findMissingKeys("unknown-runtime", configured)).toEqual([]);
});
it("returns all required keys when nothing is configured", () => {
const configured = new Set<string>();
expect(findMissingKeys("claude-code", configured)).toEqual(["ANTHROPIC_API_KEY"]);
});
it("handles empty configured set for multi-key runtimes", () => {
const configured = new Set<string>();
const result = findMissingKeys("langgraph", configured);
expect(result).toEqual(["OPENAI_API_KEY"]);
it("requires ALL envVars in a multi-env provider", () => {
const providers: ReturnType<typeof providersFromTemplate> =
providersFromTemplate({
runtime: "agent",
models: [{ id: "m", required_env: ["A", "B"] }],
});
expect(findSatisfiedProvider(providers, new Set(["A"]))).toBeNull();
expect(findSatisfiedProvider(providers, new Set(["A", "B"]))?.id).toBe("A|B");
});
});
/* ---------- getKeyLabel ---------- */
// -----------------------------------------------------------------------------
// Label helpers
// -----------------------------------------------------------------------------
describe("getKeyLabel", () => {
it("returns label for known keys", () => {
describe("getKeyLabel / getProviderLabel", () => {
it("uses KEY_LABELS for well-known keys", () => {
expect(getProviderLabel("OPENAI_API_KEY")).toBe("OpenAI");
expect(getKeyLabel("OPENAI_API_KEY")).toBe("OpenAI API Key");
expect(getKeyLabel("ANTHROPIC_API_KEY")).toBe("Anthropic API Key");
});
it("returns the key itself for unknown keys", () => {
expect(getKeyLabel("CUSTOM_SECRET")).toBe("CUSTOM_SECRET");
it("humanizes unknown env vars", () => {
expect(getProviderLabel("MY_CUSTOM_API_KEY")).toBe("My Custom");
expect(getKeyLabel("MY_CUSTOM_TOKEN")).toBe("My Custom");
});
});
/* ---------- RUNTIME_REQUIRED_KEYS ---------- */
describe("RUNTIME_REQUIRED_KEYS", () => {
it("covers all six standard runtimes", () => {
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
expect(runtimes).toContain("langgraph");
expect(runtimes).toContain("claude-code");
expect(runtimes).toContain("openclaw");
expect(runtimes).toContain("deepagents");
expect(runtimes).toContain("crewai");
expect(runtimes).toContain("autogen");
});
it("each runtime has at least one required key", () => {
for (const [runtime, keys] of Object.entries(RUNTIME_REQUIRED_KEYS)) {
expect(keys.length).toBeGreaterThan(0);
}
});
});
/* ---------- checkDeploySecrets ---------- */
// -----------------------------------------------------------------------------
// checkDeploySecrets
// -----------------------------------------------------------------------------
describe("checkDeploySecrets", () => {
it("returns ok=true when all required keys have values", async () => {
it("returns ok=true when a single-provider template's key is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
@ -124,49 +151,13 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.runtime).toBe("langgraph");
});
it("returns ok=false when required keys are missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OTHER_KEY", has_value: true, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
it("returns ok=false when secret exists but has_value is false", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
it("returns ok=true for runtimes with no required keys", async () => {
const result = await checkDeploySecrets("unknown-runtime");
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
// Should not have called fetch
expect(global.fetch).not.toHaveBeenCalled();
});
it("uses workspace-specific endpoint when workspaceId is provided", async () => {
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
@ -175,33 +166,82 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets("claude-code", "ws-123");
const result = await checkDeploySecrets(HERMES);
expect(result.ok).toBe(true);
});
it("returns ok=false with every candidate env when nothing is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(HERMES);
expect(result.ok).toBe(false);
// De-duplicated flat list across providers.
expect(new Set(result.missingKeys)).toEqual(
new Set(["HERMES_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"]),
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
});
it("treats has_value=false as not-configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
it("skips the API call entirely when the template declares no env needs", async () => {
const result = await checkDeploySecrets(UNKNOWN);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: true, created_at: "", updated_at: "" },
]),
} as Response);
await checkDeploySecrets(LANGGRAPH, "ws-123");
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/workspaces/ws-123/secrets"),
expect.any(Object),
);
});
it("uses global secrets endpoint when no workspaceId", async () => {
it("uses the global secrets endpoint when no workspaceId", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
await checkDeploySecrets("langgraph");
await checkDeploySecrets(LANGGRAPH);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/settings/secrets"),
expect.any(Object),
);
});
it("treats API failure as all keys missing (safe default)", async () => {
it("treats fetch failure as all-missing (safe default prompts the user)", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Network error"),
);
const result = await checkDeploySecrets("langgraph");
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});

View File

@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { closeWebSocketGracefully } from "../ws-close";
// Minimal test-double for WebSocket. jsdom doesn't ship a
// spec-compliant WebSocket, so we roll our own with just the bits the
// helper touches: readyState, close(), addEventListener("open") /
// ("error"). This lets us verify the graceful-close semantics without
// a live server.
function makeFakeWS(initialState: number) {
const listeners: Record<string, Array<() => void>> = {};
const ws = {
readyState: initialState,
close: vi.fn(),
addEventListener: vi.fn(
(type: string, handler: () => void, _opts?: { once?: boolean }) => {
(listeners[type] ??= []).push(handler);
},
),
removeEventListener: vi.fn(
(type: string, handler: () => void) => {
const arr = listeners[type];
if (!arr) return;
const idx = arr.indexOf(handler);
if (idx >= 0) arr.splice(idx, 1);
},
),
// Helpers for tests to fire the queued listeners.
fire(type: string) {
(listeners[type] ?? []).slice().forEach((h) => h());
},
};
return ws as unknown as WebSocket & { fire(type: string): void };
}
describe("closeWebSocketGracefully", () => {
it("calls close() immediately when the socket is OPEN", () => {
const ws = makeFakeWS(WebSocket.OPEN);
closeWebSocketGracefully(ws);
expect(ws.close).toHaveBeenCalledOnce();
});
it("calls close() immediately when the socket is CLOSING", () => {
const ws = makeFakeWS(WebSocket.CLOSING);
closeWebSocketGracefully(ws);
expect(ws.close).toHaveBeenCalledOnce();
});
it("is a no-op when the socket is already CLOSED", () => {
const ws = makeFakeWS(WebSocket.CLOSED);
closeWebSocketGracefully(ws);
expect(ws.close).not.toHaveBeenCalled();
expect(ws.addEventListener).not.toHaveBeenCalled();
});
it("defers close until 'open' when the socket is CONNECTING", () => {
const ws = makeFakeWS(WebSocket.CONNECTING);
closeWebSocketGracefully(ws);
// close() NOT called yet — handshake hasn't completed.
expect(ws.close).not.toHaveBeenCalled();
// Two listeners queued: one for 'open' (close on connect), one
// for 'error' (cancel the queued close if handshake fails).
expect(ws.addEventListener).toHaveBeenCalledWith(
"open", expect.any(Function), { once: true },
);
expect(ws.addEventListener).toHaveBeenCalledWith(
"error", expect.any(Function), { once: true },
);
// Simulate the handshake completing — close() should fire now.
(ws as unknown as { fire: (t: string) => void }).fire("open");
expect(ws.close).toHaveBeenCalledOnce();
});
it("does NOT call close() when the CONNECTING socket errors instead of opening", () => {
const ws = makeFakeWS(WebSocket.CONNECTING);
closeWebSocketGracefully(ws);
// Simulate handshake failure — the browser has already torn the
// socket down, no explicit close() needed.
(ws as unknown as { fire: (t: string) => void }).fire("error");
expect(ws.close).not.toHaveBeenCalled();
});
});

View File

@ -11,13 +11,22 @@ export const PLATFORM_URL =
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance.
// the error and render a retry affordance. Callers that know the
// endpoint is intentionally slow (org import walks a tree of
// workspaces with server-side pacing) can pass `timeoutMs` to
// override.
const DEFAULT_TIMEOUT_MS = 15_000;
export interface RequestOptions {
timeoutMs?: number;
}
async function request<T>(
method: string,
path: string,
body?: unknown
body?: unknown,
retryCount = 0,
options?: RequestOptions,
): Promise<T> {
// SaaS cross-origin shape:
// - X-Molecule-Org-Slug: derived from window.location.hostname by
@ -36,14 +45,35 @@ async function request<T>(
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
// Transient rate-limit recovery. A single IP bucket can momentarily
// spike on page load (several panels hydrate simultaneously). Instead
// of bubbling up a 429 that blanks the Canvas, wait the
// Retry-After window and try once — any further 429 surfaces normally.
// GET / idempotent methods only; never auto-retry mutations.
if (res.status === 429 && retryCount === 0 && method === "GET") {
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
const delayMs = Number.isFinite(retryAfter) ? Math.min(retryAfter, 20) * 1000 : 2000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return request<T>(method, path, body, retryCount + 1, options);
}
if (res.status === 401) {
// Session expired or credentials lost — redirect to login once.
// Import dynamically to avoid circular dependency with auth.ts.
const { redirectToLogin } = await import("./auth");
redirectToLogin("sign-in");
throw new Error("Session expired — redirecting to login");
// Session expired or credentials lost. On SaaS (tenant subdomain)
// the login page lives at /cp/auth/login and is mounted by the
// control-plane reverse proxy — redirect. On self-hosted / local
// dev / Vercel preview there IS no /cp/* mount, so redirecting
// would navigate to a 404 ("404 page not found") instead of the
// real error the user should see. In that case, throw instead
// and let the caller render a meaningful failure (retry button,
// error banner, etc.).
if (slug) {
const { redirectToLogin } = await import("./auth");
redirectToLogin("sign-in");
throw new Error("Session expired — redirecting to login");
}
throw new Error(`API ${method} ${path}: 401 ${await res.text()}`);
}
if (!res.ok) {
const text = await res.text();
@ -53,9 +83,9 @@ async function request<T>(
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
patch: <T>(path: string, body?: unknown) => request<T>("PATCH", path, body),
put: <T>(path: string, body?: unknown) => request<T>("PUT", path, body),
del: <T>(path: string) => request<T>("DELETE", path),
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, 0, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, 0, options),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PATCH", path, body, 0, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, 0, options),
del: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, 0, options),
};

View File

@ -1,38 +1,37 @@
/**
* Pre-deploy secret check per runtime.
* Pre-deploy secret check driven by the template's config.yaml.
*
* Before a workspace is deployed, validates that all required secrets/env vars
* are configured for the target runtime. Each runtime defines its own set of
* required keys (derived from each runtime's config.yaml `env.required` field).
* The single source of truth for which env vars a workspace needs is
* each template repo's config.yaml the `runtime_config.models[].required_env`
* array names the key(s) required per model, and `runtime_config.required_env`
* names any AND-required keys at the runtime level. The Go `/templates`
* handler parses these and exposes them as `models` and `required_env` on
* each template summary.
*
* This module consumes that shape; it does NOT hardcode a per-runtime
* provider table. When a template declares alternative models (e.g.
* Hermes supports 35 models across 8 providers), the unique required_env
* tuples become the provider options shown in the picker modal.
*/
import { api } from "./api";
/* ---------- Required keys per runtime ---------- */
/* ---------- Types matching the /templates response ---------- */
export const RUNTIME_REQUIRED_KEYS: Record<string, string[]> = {
langgraph: ["OPENAI_API_KEY"],
"claude-code": ["ANTHROPIC_API_KEY"],
openclaw: ["OPENAI_API_KEY"],
deepagents: ["OPENAI_API_KEY"],
crewai: ["OPENAI_API_KEY"],
autogen: ["OPENAI_API_KEY"],
hermes: ["OPENROUTER_API_KEY"],
"gemini-cli": ["GOOGLE_API_KEY"],
};
export interface ModelSpec {
id: string;
name?: string;
required_env?: string[];
}
/** Human-readable labels for common secret keys */
export const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: "OpenAI API Key",
ANTHROPIC_API_KEY: "Anthropic API Key",
GOOGLE_API_KEY: "Google AI API Key",
SERP_API_KEY: "SERP API Key",
OPENROUTER_API_KEY: "OpenRouter API Key",
HERMES_API_KEY: "Nous Research API Key",
DEEPSEEK_API_KEY: "DeepSeek API Key",
};
/* ---------- Types ---------- */
/** Minimal template shape consumed by the preflight check. Any object
* that matches this subset of the `/templates` response works. */
export interface TemplateLike {
runtime: string;
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
}
export interface SecretEntry {
key: string;
@ -44,63 +43,184 @@ export interface SecretEntry {
export interface PreflightResult {
ok: boolean;
/** Flat list of env var names needed for the legacy modal path and
* for callers that want a single display of "what's missing". */
missingKeys: string[];
/** Grouped provider options derived from the template. When length 2
* the modal renders a picker; length 1 means exactly one provider is
* required (AllKeysModal renders the N envVars inline). */
providers: ProviderChoice[];
runtime: string;
}
/* ---------- Pure helpers (easily testable) ---------- */
/* ---------- Provider options ---------- */
/** Get required env keys for a given runtime. Returns empty array for unknown runtimes. */
export function getRequiredKeys(runtime: string): string[] {
return RUNTIME_REQUIRED_KEYS[runtime] ?? [];
/** One row in the provider picker. `envVars` is the set of keys required
* TOGETHER to satisfy this option (usually length 1 e.g. just
* OPENROUTER_API_KEY). When length 2 all must be saved. */
export interface ProviderChoice {
/** Stable id for React keys + picker value — the sorted envVars joined. */
id: string;
/** Human label, e.g. "OpenRouter" or "OpenAI + Serper". */
label: string;
/** Env vars required for this provider option. */
envVars: string[];
/** Short rationale shown under the option, optional. */
note?: string;
}
/** Given a runtime and a set of configured key names, return which keys are missing. */
export function findMissingKeys(
runtime: string,
configuredKeys: Set<string>,
): string[] {
return getRequiredKeys(runtime).filter((k) => !configuredKeys.has(k));
}
/** Human-readable labels for well-known secret keys. Anything not in
* this table falls back to a humanized form of the env var. */
export const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: "OpenAI",
ANTHROPIC_API_KEY: "Anthropic",
GOOGLE_API_KEY: "Google AI",
GEMINI_API_KEY: "Google Gemini",
SERP_API_KEY: "SERP",
SERPER_API_KEY: "Serper",
OPENROUTER_API_KEY: "OpenRouter",
HERMES_API_KEY: "Nous Research (Hermes native)",
DEEPSEEK_API_KEY: "DeepSeek",
GLM_API_KEY: "z.ai GLM",
KIMI_API_KEY: "Moonshot Kimi",
MINIMAX_API_KEY: "MiniMax",
KILOCODE_API_KEY: "Kilo Code",
CLAUDE_CODE_OAUTH_TOKEN: "Claude Code subscription",
};
/** Get human-readable label for a key, or fall back to the key itself. */
/** Full "API Key" label used for input field headers. */
export function getKeyLabel(key: string): string {
return KEY_LABELS[key] ?? key;
const base = KEY_LABELS[key];
if (base) return `${base} API Key`;
return humanizeEnvVar(key);
}
/* ---------- API-calling preflight check ---------- */
/** Short provider name used in the picker (no trailing "API Key"). */
export function getProviderLabel(key: string): string {
return KEY_LABELS[key] ?? humanizeEnvVar(key);
}
function humanizeEnvVar(key: string): string {
return key
.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "")
.split(/[_-]/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
/**
* Fetch configured secrets from the platform and check whether all required
* keys for the target runtime are present.
* Derive the provider options for a template from its declared shape.
*
* If `workspaceId` is provided, fetches the merged (global + workspace) secret
* list for that workspace. Otherwise falls back to global secrets only.
* 1. `models[].required_env` each unique (sorted) tuple becomes a
* provider option. E.g. Hermes exposes 8 options (Nous, OpenRouter,
* Anthropic, Gemini, DeepSeek, GLM, Kimi, Kilocode) even though it
* lists 35 models. Insertion order is preserved so the template's
* author controls which provider is offered first.
* 2. If `models` is empty or has no required_env, fall back to the
* top-level `required_env` as a single all-required option.
* 3. If neither is declared, return [] no preflight needed.
*
* Models with `required_env: []` (local / self-hosted endpoints) are
* skipped when computing options; they never block a deploy.
*/
export async function checkDeploySecrets(
runtime: string,
workspaceId?: string,
): Promise<PreflightResult> {
const requiredKeys = getRequiredKeys(runtime);
if (requiredKeys.length === 0) {
return { ok: true, missingKeys: [], runtime };
export function providersFromTemplate(template: TemplateLike): ProviderChoice[] {
const out: ProviderChoice[] = [];
const seen = new Set<string>();
const modelCount: Record<string, number> = {};
for (const m of template.models ?? []) {
const envs = m.required_env ?? [];
if (envs.length === 0) continue;
const id = [...envs].sort().join("|");
modelCount[id] = (modelCount[id] ?? 0) + 1;
if (seen.has(id)) continue;
seen.add(id);
out.push({
id,
envVars: envs,
label: envs.map(getProviderLabel).join(" + "),
});
}
// Decorate labels with model-count hints when multiple models share
// the same provider. Gives the user context: "OpenRouter (14 models)".
for (const p of out) {
const n = modelCount[p.id];
if (n && n > 1) p.label = `${p.label} (${n} models)`;
}
if (out.length === 0 && template.required_env?.length) {
const envs = template.required_env;
out.push({
id: [...envs].sort().join("|"),
envVars: envs,
label: envs.map(getProviderLabel).join(" + "),
});
}
return out;
}
/** Helper: is any single provider option already satisfied by the set of
* configured keys? A provider is satisfied when EVERY envVar it requires
* is present. Returns the first such option or null. */
export function findSatisfiedProvider(
providers: ProviderChoice[],
configured: Set<string>,
): ProviderChoice | null {
for (const p of providers) {
if (p.envVars.every((k) => configured.has(k))) return p;
}
return null;
}
/* ---------- Preflight ---------- */
/**
* Fetch configured secrets from the platform and decide whether the
* workspace can deploy. When `workspaceId` is provided the merged
* (global + workspace) secrets are checked; otherwise only globals.
*
* Returns `ok=true` immediately if any provider option's env vars are
* already configured. Otherwise returns all candidate env vars flat in
* `missingKeys` plus the grouped `providers` list for the picker.
*/
export async function checkDeploySecrets(
template: TemplateLike,
workspaceId?: string,
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
if (providers.length === 0) {
// Template declares no env requirements — nothing to preflight.
return { ok: true, missingKeys: [], providers: [], runtime };
}
let configured: Set<string>;
try {
const secrets = workspaceId
? await api.get<SecretEntry[]>(`/workspaces/${workspaceId}/secrets`)
: await api.get<SecretEntry[]>("/settings/secrets");
const configuredKeys = new Set(
secrets.filter((s) => s.has_value).map((s) => s.key),
);
const missingKeys = findMissingKeys(runtime, configuredKeys);
return { ok: missingKeys.length === 0, missingKeys, runtime };
configured = new Set(secrets.filter((s) => s.has_value).map((s) => s.key));
} catch (error) {
// Log the error before falling back — aids debugging when the API is down.
console.error("[deploy-preflight] Failed to check secrets, assuming all missing:", error);
// If we can't reach the secrets API, assume missing — safer to prompt the user.
return { ok: false, missingKeys: requiredKeys, runtime };
console.error(
"[deploy-preflight] Failed to read secrets, assuming all missing:",
error,
);
// Safer to prompt the user than to silently deploy.
configured = new Set();
}
if (findSatisfiedProvider(providers, configured)) {
return { ok: true, missingKeys: [], providers, runtime };
}
// Nothing configured — surface every candidate env var so the modal
// can render the picker or the all-keys fallback.
const missingKeys = Array.from(
new Set(providers.flatMap((p) => p.envVars)),
);
return { ok: false, missingKeys, providers, runtime };
}

View File

@ -0,0 +1,38 @@
/**
* closeWebSocketGracefully closes a WebSocket without tripping the
* browser console warning "WebSocket is closed before the connection is
* established". That warning fires when `ws.close()` runs while
* readyState is still CONNECTING (0) most often triggered by React
* StrictMode's double-invoked useEffect in dev, or any rapid
* mount/unmount (tab switch, route change) during the WS handshake.
*
* Behaviour by state:
* - OPEN / CLOSING: close immediately (the normal path).
* - CONNECTING: defer the close until 'open' fires, so the
* browser sees a full handshake before the shutdown.
* - CLOSED: no-op.
*
* Returns the ws unchanged for chaining.
*/
export function closeWebSocketGracefully(ws: WebSocket): WebSocket {
const state = ws.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CLOSING) {
ws.close();
return ws;
}
if (state === WebSocket.CONNECTING) {
const onOpen = () => {
ws.close();
};
ws.addEventListener("open", onOpen, { once: true });
// Also wire an error listener — if the handshake fails we don't
// need to close (the browser already tore it down) and we should
// clear the queued onOpen handler.
ws.addEventListener(
"error",
() => ws.removeEventListener("open", onOpen),
{ once: true },
);
}
return ws;
}

View File

@ -361,7 +361,7 @@ describe("handleCanvasEvent WORKSPACE_REMOVED", () => {
const { nodes: updatedNodes } = set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] };
const updatedChild = updatedNodes.find((n) => n.id === "child")!;
expect(updatedChild.data.parentId).toBe("parent");
expect(updatedChild.hidden).toBe(true); // still has a parent
expect(updatedChild.parentId).toBe("parent"); // RF binding re-pointed
});
it("reparents children to null when root node is removed", () => {
@ -374,7 +374,7 @@ describe("handleCanvasEvent WORKSPACE_REMOVED", () => {
const { nodes: updatedNodes } = set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] };
const updatedChild = updatedNodes.find((n) => n.id === "child")!;
expect(updatedChild.data.parentId).toBeNull();
expect(updatedChild.hidden).toBe(false);
expect(updatedChild.parentId).toBeUndefined();
});
it("removes edges connected to the removed workspace", () => {

View File

@ -110,7 +110,10 @@ describe("buildNodesAndEdges parent + child workspaces", () => {
expect(edges).toHaveLength(0);
});
it("marks parent as visible and child as hidden", () => {
it("binds child to parent via React Flow's native parentId", () => {
// Children are first-class nodes now (rendered as full cards inside
// their parent via RF's parentId). No `hidden` flag anymore — the
// nesting is visual, not hide-and-show.
const { nodes } = buildNodesAndEdges([
makeWS({ id: "parent" }),
makeWS({ id: "child", parent_id: "parent" }),
@ -120,7 +123,9 @@ describe("buildNodesAndEdges parent + child workspaces", () => {
const child = nodes.find((n) => n.id === "child")!;
expect(parent.hidden).toBeFalsy();
expect(child.hidden).toBe(true);
expect(child.hidden).toBeFalsy();
expect(parent.parentId).toBeUndefined();
expect(child.parentId).toBe("parent");
});
it("stores parent_id in child node data as parentId", () => {
@ -157,9 +162,9 @@ describe("buildNodesAndEdges deeply nested hierarchy", () => {
expect(nodes).toHaveLength(3);
expect(edges).toHaveLength(0);
expect(nodes.find((n) => n.id === "root")!.hidden).toBeFalsy();
expect(nodes.find((n) => n.id === "mid")!.hidden).toBe(true);
expect(nodes.find((n) => n.id === "leaf")!.hidden).toBe(true);
expect(nodes.find((n) => n.id === "root")!.parentId).toBeUndefined();
expect(nodes.find((n) => n.id === "mid")!.parentId).toBe("root");
expect(nodes.find((n) => n.id === "leaf")!.parentId).toBe("mid");
expect(nodes.find((n) => n.id === "mid")!.data.parentId).toBe("root");
expect(nodes.find((n) => n.id === "leaf")!.data.parentId).toBe("mid");
@ -175,9 +180,9 @@ describe("buildNodesAndEdges deeply nested hierarchy", () => {
const { nodes } = buildNodesAndEdges(workspaces);
expect(nodes).toHaveLength(3);
expect(nodes.find((n) => n.id === "root-a")!.hidden).toBeFalsy();
expect(nodes.find((n) => n.id === "root-b")!.hidden).toBeFalsy();
expect(nodes.find((n) => n.id === "child-a")!.hidden).toBe(true);
expect(nodes.find((n) => n.id === "root-a")!.parentId).toBeUndefined();
expect(nodes.find((n) => n.id === "root-b")!.parentId).toBeUndefined();
expect(nodes.find((n) => n.id === "child-a")!.parentId).toBe("root-a");
});
});
@ -358,3 +363,58 @@ describe("buildNodesAndEdges layoutOverrides applied", () => {
expect(nodes[0].position).toEqual({ x: 100, y: 200 });
});
});
// ---------- Rescue heuristic for out-of-bounds children ----------
//
// Parent starts at min size for its child count (2-col grid). For a
// parent with one child, parentMinSize(1) is ~300 × 200. Each of the
// tests below fixes the parent origin at (1000, 500) so the test
// cases read cleanly.
describe("buildNodesAndEdges child rescue heuristic", () => {
const PARENT_ABS = { x: 1000, y: 500 };
function scenario(childAbs: { x: number; y: number }) {
return buildNodesAndEdges([
makeWS({ id: "p", name: "Parent", x: PARENT_ABS.x, y: PARENT_ABS.y }),
makeWS({ id: "c", name: "Child", parent_id: "p", x: childAbs.x, y: childAbs.y }),
]).nodes.find((n) => n.id === "c")!;
}
it("rescues a child whose bbox falls entirely outside the parent (screenshot case)", () => {
// Child abs (580, 795) with parent at (1000, 500) → rel (-420, 295)
// The child's right edge sits at -160, entirely left of parent.
// Expect the grid slot, not the negative stored position.
const child = scenario({ x: 580, y: 795 });
expect(child.position.x).toBeGreaterThanOrEqual(0);
expect(child.position.y).toBeGreaterThanOrEqual(0);
});
it("keeps a child whose stored position drifts slightly negative (user moved parent past child)", () => {
// Child abs (960, 460), parent (1000, 500) → rel (-40, -40).
// Child right/bottom edges still overlap the parent bbox; this is
// a recoverable layout, not corruption. Leave it alone.
const child = scenario({ x: 960, y: 460 });
expect(child.position).toEqual({ x: -40, y: -40 });
});
it("rescues a child stored with legacy huge-positive coords", () => {
// Abs (50000, 50000) with parent at (1000, 500) → rel (49000, 49500).
// No overlap possible with any reasonable parent size — rescue.
const child = scenario({ x: 50000, y: 50000 });
expect(child.position.x).toBeLessThan(1000);
expect(child.position.y).toBeLessThan(1000);
});
it("keeps a child placed inside a user-resized parent past the initial min size", () => {
// parentMinSize(1) is ~300×200. A child placed at rel (450, 300)
// would be past the initial min bounds but INSIDE a user-grown
// parent of, say, 600×400. We can't know the user's resized size
// from topology alone — but the child's bbox still overlaps the
// initial parent bbox on at least the X axis because its top-left
// is only 450px in (less than the computed parent width for most
// child counts). Verify the intermediate case is preserved.
const child = scenario({ x: PARENT_ABS.x + 100, y: PARENT_ABS.y + 50 });
expect(child.position).toEqual({ x: 100, y: 50 });
});
});

View File

@ -92,7 +92,11 @@ describe("hydrate", () => {
expect(edges).toHaveLength(0);
});
it("sets hidden=true for nodes with parent_id", () => {
it("binds children to their parent via React Flow parentId", () => {
// The old model hid child nodes + embedded them as chips inside the
// parent card. The new model renders every workspace as a first-class
// card, using React Flow's native parentId to group them so moving
// the parent carries the children along.
const workspaces = [
makeWS({ id: "parent", name: "Parent" }),
makeWS({ id: "child", name: "Child", parent_id: "parent" }),
@ -105,7 +109,9 @@ describe("hydrate", () => {
const child = nodes.find((n) => n.id === "child")!;
expect(parent.hidden).toBeFalsy();
expect(child.hidden).toBe(true);
expect(child.hidden).toBeFalsy();
expect(parent.parentId).toBeUndefined();
expect(child.parentId).toBe("parent");
expect(child.data.parentId).toBe("parent");
});
@ -269,7 +275,7 @@ describe("applyEvent", () => {
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: { name: "Fresh", tier: 2 },
payload: { name: "Fresh", tier: 2, runtime: "hermes" },
})
);
@ -281,6 +287,9 @@ describe("applyEvent", () => {
expect(newNode.data.name).toBe("Fresh");
expect(newNode.data.tier).toBe(2);
expect(newNode.data.status).toBe("provisioning");
// Runtime must flow through the provisioning event so the side-panel
// pill renders the real runtime instead of "unknown" until a refetch.
expect(newNode.data.runtime).toBe("hermes");
// Position is offset by existing node count * 40
expect(newNode.position.x).toBeGreaterThanOrEqual(0);
expect(newNode.position.y).toBeGreaterThanOrEqual(0);
@ -328,7 +337,7 @@ describe("applyEvent", () => {
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("ws-2");
expect(nodes[0].data.parentId).toBeNull();
expect(nodes[0].hidden).toBe(false);
expect(nodes[0].parentId).toBeUndefined();
});
it("WORKSPACE_REMOVED clears selectedNodeId if removed", () => {
@ -451,7 +460,7 @@ describe("removeNode", () => {
const leaf = useCanvasStore.getState().nodes.find((n) => n.id === "leaf")!;
expect(leaf.data.parentId).toBe("root");
expect(leaf.hidden).toBe(true); // still has a parent
expect(leaf.parentId).toBe("root"); // RF binding also re-pointed
});
it("reparents children to null when root is deleted", () => {
@ -459,7 +468,7 @@ describe("removeNode", () => {
const mid = useCanvasStore.getState().nodes.find((n) => n.id === "mid")!;
expect(mid.data.parentId).toBeNull();
expect(mid.hidden).toBe(false);
expect(mid.parentId).toBeUndefined();
});
it("clears selection if removed node was selected", () => {
@ -652,23 +661,21 @@ describe("nestNode", () => {
]);
});
it("optimistically updates parentId and hidden", async () => {
it("optimistically updates parentId and the RF parent binding", async () => {
await useCanvasStore.getState().nestNode("b", "a");
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
expect(b.data.parentId).toBe("a");
expect(b.hidden).toBe(true);
expect(b.parentId).toBe("a");
});
it("un-nesting sets parentId to null and shows node", async () => {
// First nest
it("un-nesting clears parentId and the RF binding", async () => {
await useCanvasStore.getState().nestNode("b", "a");
// Then un-nest
await useCanvasStore.getState().nestNode("b", null);
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
expect(b.data.parentId).toBeNull();
expect(b.hidden).toBe(false);
expect(b.parentId).toBeUndefined();
});
it("skips when parentId is already the target", async () => {
@ -691,7 +698,7 @@ describe("nestNode", () => {
// Should revert to original state (no parent)
const b = useCanvasStore.getState().nodes.find((n) => n.id === "b")!;
expect(b.data.parentId).toBeNull();
expect(b.hidden).toBe(false);
expect(b.parentId).toBeUndefined();
});
});
@ -848,3 +855,238 @@ describe("TASK_UPDATED edge cases", () => {
expect(ws2.data.currentTask).toBe("Task B"); // unchanged
});
});
// ---------- setCollapsed round-trip ----------
describe("setCollapsed", () => {
beforeEach(() => {
// Three-level chain so we can test that collapsing an ancestor
// hides all descendants AND that expanding it correctly preserves
// any intermediate collapsed state (otherwise setCollapsed and
// hydrate produce different hidden flags — the drift the review
// flagged as Critical).
useCanvasStore.getState().hydrate([
makeWS({ id: "a", name: "A" }),
makeWS({ id: "b", name: "B", parent_id: "a" }),
makeWS({ id: "c", name: "C", parent_id: "b" }),
]);
});
it("hides the entire subtree when the root is collapsed", () => {
useCanvasStore.getState().setCollapsed("a", true);
const { nodes } = useCanvasStore.getState();
expect(nodes.find((n) => n.id === "a")!.hidden).toBeFalsy();
expect(nodes.find((n) => n.id === "b")!.hidden).toBe(true);
expect(nodes.find((n) => n.id === "c")!.hidden).toBe(true);
expect(nodes.find((n) => n.id === "a")!.data.collapsed).toBe(true);
});
it("keeps descendants hidden when an ancestor is un-collapsed but a middle parent is still collapsed", () => {
// Collapse both A and B, then expand A. C must stay hidden because
// B — its immediate parent — is still collapsed. Before the fix,
// setCollapsed naively unhid every descendant of A and drifted from
// what hydrate would produce.
useCanvasStore.getState().setCollapsed("a", true);
useCanvasStore.getState().setCollapsed("b", true);
useCanvasStore.getState().setCollapsed("a", false);
const { nodes } = useCanvasStore.getState();
expect(nodes.find((n) => n.id === "b")!.hidden).toBeFalsy();
expect(nodes.find((n) => n.id === "c")!.hidden).toBe(true);
});
it("matches hydrate's hidden flags (no drift on snapshot refresh)", () => {
// Run the same scenario through setCollapsed, then re-hydrate from
// an equivalent server snapshot and assert the hidden flags agree.
useCanvasStore.getState().setCollapsed("a", true);
const afterCollapse = useCanvasStore.getState().nodes.map((n) => ({
id: n.id,
hidden: !!n.hidden,
}));
useCanvasStore.getState().hydrate([
makeWS({ id: "a", name: "A", collapsed: true }),
makeWS({ id: "b", name: "B", parent_id: "a" }),
makeWS({ id: "c", name: "C", parent_id: "b" }),
]);
const afterHydrate = useCanvasStore.getState().nodes.map((n) => ({
id: n.id,
hidden: !!n.hidden,
}));
expect(afterHydrate).toEqual(afterCollapse);
});
it("sizes the expanded parent to fit nested-parent children, not leaf-count", () => {
// Regression: when a collapsed parent contains a child that is
// itself a parent (CTO → Dev Lead → 6 engineers), expanding must
// use each direct child's actual rendered size — not the
// leaf-count formula. Otherwise the container is too small and
// Dev Lead (wide enough for 6 engineers in a grid) overflows.
useCanvasStore.getState().hydrate([
makeWS({ id: "cto", name: "CTO", collapsed: true }),
makeWS({ id: "devLead", name: "Dev Lead", parent_id: "cto" }),
makeWS({ id: "fe", name: "Frontend", parent_id: "devLead" }),
makeWS({ id: "be", name: "Backend", parent_id: "devLead" }),
makeWS({ id: "mo", name: "Mobile", parent_id: "devLead" }),
makeWS({ id: "do", name: "DevOps", parent_id: "devLead" }),
makeWS({ id: "se", name: "Security", parent_id: "devLead" }),
makeWS({ id: "qa", name: "QA", parent_id: "devLead" }),
]);
const devLeadNode = useCanvasStore
.getState()
.nodes.find((n) => n.id === "devLead")!;
const devLeadW = devLeadNode.width as number;
useCanvasStore.getState().setCollapsed("cto", false);
const ctoAfter = useCanvasStore
.getState()
.nodes.find((n) => n.id === "cto")!;
// CTO's new width must be wide enough to host its Dev Lead child
// plus the parent's own padding. Leaf-count formula would yield
// ~272 (one 240px leaf slot); subtree-aware should be ≥ Dev Lead
// plus side padding.
expect(ctoAfter.width).toBeGreaterThanOrEqual(devLeadW);
});
});
// ---------- bumpZOrder ----------
describe("bumpZOrder", () => {
beforeEach(() => {
useCanvasStore.getState().hydrate([
makeWS({ id: "r1", name: "R1" }),
makeWS({ id: "r2", name: "R2" }),
makeWS({ id: "r3", name: "R3" }),
]);
});
it("swaps with the neighbour in the bump direction (no drift on identical zIndex)", () => {
// Fresh topology: all three siblings start at zIndex=0 (depth=0).
// Bumping r2 forward must put it above exactly one sibling, not
// arbitrarily far ahead.
useCanvasStore.getState().bumpZOrder("r2", 1);
const nodes = useCanvasStore.getState().nodes;
const r1Z = nodes.find((n) => n.id === "r1")!.zIndex ?? 0;
const r2Z = nodes.find((n) => n.id === "r2")!.zIndex ?? 0;
const r3Z = nodes.find((n) => n.id === "r3")!.zIndex ?? 0;
// r2 now above at least one neighbour.
expect(r2Z).toBeGreaterThan(Math.min(r1Z, r3Z));
// Bumping once more swaps with the remaining one — not unbounded.
useCanvasStore.getState().bumpZOrder("r2", 1);
const r2ZAfter = useCanvasStore.getState().nodes.find((n) => n.id === "r2")!.zIndex ?? 0;
expect(r2ZAfter).toBeLessThanOrEqual(r2Z + 2);
});
it("no-ops at the edge of the sibling list", () => {
const beforeZ = useCanvasStore.getState().nodes.map((n) => n.zIndex ?? 0);
// First sibling bumped backward has no earlier neighbour.
useCanvasStore.getState().bumpZOrder("r1", -1);
const afterZ = useCanvasStore.getState().nodes.map((n) => n.zIndex ?? 0);
expect(afterZ).toEqual(beforeZ);
});
});
// ---------- batchNest ----------
describe("batchNest", () => {
beforeEach(() => {
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
// Scenario: two root nodes (a, b) and one nested under a (a-child).
// Tests below re-parent various subsets into `target`.
useCanvasStore.getState().hydrate([
makeWS({ id: "target", name: "Target", x: 1000, y: 0 }),
makeWS({ id: "a", name: "A", x: 0, y: 0 }),
makeWS({ id: "b", name: "B", x: 200, y: 0 }),
makeWS({ id: "a-child", name: "A/Child", parent_id: "a", x: 50, y: 50 }),
]);
});
it("re-parents every selected root into the target via one PATCH each", async () => {
const mock = global.fetch as ReturnType<typeof vi.fn>;
mock.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
);
// Clear any PATCHes that hydrate's computeAutoLayout may have fired
// (auto-positioned workspaces trigger a savePosition → PATCH).
mock.mockClear();
await useCanvasStore.getState().batchNest(["a", "b"], "target");
const nodes = useCanvasStore.getState().nodes;
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBe("target");
expect(nodes.find((n) => n.id === "b")!.data.parentId).toBe("target");
// Every PATCH fired by batchNest should target /workspaces/<id>
// and carry `parent_id: "target"` plus absolute x,y. One per root.
const nestPatchCalls = mock.mock.calls.filter((c) => {
const init = c[1] as RequestInit | undefined;
if (init?.method !== "PATCH") return false;
const body = init.body ? JSON.parse(init.body as string) : {};
return body.parent_id === "target";
});
expect(nestPatchCalls).toHaveLength(2);
for (const call of nestPatchCalls) {
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.x).toBeTypeOf("number");
expect(body.y).toBeTypeOf("number");
}
});
it("filters out selected descendants so a subtree moves intact", async () => {
// User selects both A AND its child A/Child, then drags into target.
// Intent: move the A subtree — A/Child stays under A, not target.
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
);
await useCanvasStore.getState().batchNest(["a", "a-child"], "target");
const nodes = useCanvasStore.getState().nodes;
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBe("target");
// The descendant is NOT independently re-parented; its parent is still A.
expect(nodes.find((n) => n.id === "a-child")!.data.parentId).toBe("a");
});
it("rolls back only the nodes whose PATCH rejected", async () => {
// Reject the PATCH for `a`, accept the one for `b`.
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (typeof url === "string" && url.endsWith("/workspaces/a")) {
return Promise.reject(new Error("network"));
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
} as Response);
});
await useCanvasStore.getState().batchNest(["a", "b"], "target");
const nodes = useCanvasStore.getState().nodes;
// `a` rolled back to its original parent (null), `b` stayed committed.
expect(nodes.find((n) => n.id === "a")!.data.parentId).toBeNull();
expect(nodes.find((n) => n.id === "b")!.data.parentId).toBe("target");
});
it("filters out all selected descendants in a three-level chain", async () => {
// Re-hydrate to a chain A → B → C. User selects all three.
// Expected: only A is planned for re-parent; B and C ride with it
// via React Flow's parent binding.
useCanvasStore.getState().hydrate([
makeWS({ id: "target", name: "Target", x: 2000, y: 0 }),
makeWS({ id: "A", name: "A", x: 0, y: 0 }),
makeWS({ id: "B", name: "B", parent_id: "A", x: 50, y: 50 }),
makeWS({ id: "C", name: "C", parent_id: "B", x: 10, y: 10 }),
]);
const mock = global.fetch as ReturnType<typeof vi.fn>;
mock.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
);
mock.mockClear();
await useCanvasStore.getState().batchNest(["A", "B", "C"], "target");
const nodes = useCanvasStore.getState().nodes;
expect(nodes.find((n) => n.id === "A")!.data.parentId).toBe("target");
expect(nodes.find((n) => n.id === "B")!.data.parentId).toBe("A");
expect(nodes.find((n) => n.id === "C")!.data.parentId).toBe("B");
// Exactly one nest-PATCH (for A). B and C weren't re-parented.
const nestPatches = mock.mock.calls.filter((c) => {
const init = c[1] as RequestInit | undefined;
if (init?.method !== "PATCH") return false;
const body = init.body ? JSON.parse(init.body as string) : {};
return body.parent_id === "target";
});
expect(nestPatches).toHaveLength(1);
});
});

View File

@ -145,6 +145,7 @@ export function handleCanvasEvent(
url: "",
parentId: null,
currentTask: "",
runtime: (msg.payload.runtime as string) ?? "",
needsRestart: false,
},
},
@ -173,7 +174,7 @@ export function handleCanvasEvent(
n.data.parentId === msg.workspace_id
? {
...n,
hidden: !!parentOfRemoved,
parentId: parentOfRemoved ?? undefined,
data: { ...n.data, parentId: parentOfRemoved },
}
: n

View File

@ -5,6 +5,176 @@ import type { WorkspaceNodeData } from "./canvas";
const H_SPACING = 320;
const V_SPACING = 200;
// Default card footprint we use when we don't yet have a measured size
// (first render, before React Flow reports dimensions). These match the
// min-width / min-height that WorkspaceNode.tsx sets, so a parent built
// from them will never start too small for its children on first paint.
/**
* Re-orders a React Flow node array so parents always appear BEFORE
* their children. React Flow requires this ordering; when it's
* violated RF logs "Parent node ... not found" and renders the child
* at canvas-absolute coords (losing the parent-relative transform).
*
* We call this every time nestNode / batchNest mutates parentId
* without a re-sort a freshly-nested child can appear AFTER its new
* parent in the array, which breaks the next drag.
*/
export function sortParentsBeforeChildren<T extends { id: string; parentId?: string }>(
nodes: T[],
): T[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const visited = new Set<string>();
const out: T[] = [];
const visit = (n: T) => {
if (visited.has(n.id)) return;
if (n.parentId) {
const parent = byId.get(n.parentId);
if (parent && !visited.has(parent.id)) visit(parent);
}
visited.add(n.id);
out.push(n);
};
for (const n of nodes) visit(n);
return out;
}
// Grid-slot defaults for children laid under a parent. The card
// component (WorkspaceNode.tsx) sets `max-w-[240px]` on leaves, so a
// slot stride of CHILD_DEFAULT_WIDTH + CHILD_GUTTER guarantees cards
// never bleed into their neighbour's slot. Keep these in sync with
// the Go mirror in workspace-server/internal/handlers/org.go —
// changing one without the other leads to import-time / runtime drift.
export const CHILD_DEFAULT_WIDTH = 240;
export const CHILD_DEFAULT_HEIGHT = 130;
// Parent header space — reserves room above the child grid so the
// parent's own name + runtime pill + clamped role + currentTask
// banner aren't covered by the first row of child cards. The
// currentTask banner appears on freshly-provisioning agents (initial
// prompt gets queued as their current task) and adds ~30px below the
// role; without this headroom, the first child overlaps the amber
// banner and makes the parent card look broken on import. Keep in
// sync with the Go mirror in org.go.
export const PARENT_HEADER_PADDING = 130;
export const PARENT_SIDE_PADDING = 16;
export const PARENT_BOTTOM_PADDING = 16;
export const CHILD_GUTTER = 14;
/**
* A deterministic grid slot for the n-th child inside a parent, counted
* left-to-right then top-to-bottom. Used to lay out org-imported teams
* and to rescue children whose stored position puts them outside the
* parent's bounding box. 2-column grid is wide enough to read but
* narrow enough to keep the parent card from becoming a widescreen.
*
* Leaf-sized slots only for variable-size siblings (mix of leaves
* and nested parents), use `childSlotInGrid` below instead.
*/
export function defaultChildSlot(index: number): { x: number; y: number } {
const col = index % 2;
const row = Math.floor(index / 2);
const x = PARENT_SIDE_PADDING + col * (CHILD_DEFAULT_WIDTH + CHILD_GUTTER);
const y =
PARENT_HEADER_PADDING + row * (CHILD_DEFAULT_HEIGHT + CHILD_GUTTER);
return { x, y };
}
export interface NodeSize {
width: number;
height: number;
}
/** Grid column count for laying children inside a parent. Matches the
* Go server mirror (childGridColumnCount). */
const GRID_COLS = 2;
/** Utility: per-row max height in a size[] laid out column-major. */
function rowHeightsOf(sizes: NodeSize[], cols: number): number[] {
const rows = Math.ceil(sizes.length / cols);
const out = new Array(rows).fill(0);
sizes.forEach((s, i) => {
const row = Math.floor(i / cols);
out[row] = Math.max(out[row], s.height);
});
return out;
}
/** Uniform column width = max of all sibling widths. Keeps the grid
* rectangular (alternative: variable col widths visually unstable
* when one sibling is much wider than the rest). */
function colWidthOf(sizes: NodeSize[]): number {
return sizes.reduce((m, s) => Math.max(m, s.width), 0);
}
/**
* Grid slot for the n-th sibling when siblings have variable sizes
* (e.g., a mix of leaves and nested parents). Uniform column width +
* per-row max height, so bigger nested parents push their row down
* without displacing columns.
*/
export function childSlotInGrid(
index: number,
siblingSizes: NodeSize[],
): { x: number; y: number } {
if (siblingSizes.length === 0) return { x: PARENT_SIDE_PADDING, y: PARENT_HEADER_PADDING };
const cols = Math.min(GRID_COLS, siblingSizes.length);
const col = index % cols;
const row = Math.floor(index / cols);
const colW = colWidthOf(siblingSizes);
const rowHs = rowHeightsOf(siblingSizes, cols);
const x = PARENT_SIDE_PADDING + col * (colW + CHILD_GUTTER);
let y = PARENT_HEADER_PADDING;
for (let r = 0; r < row; r++) y += rowHs[r] + CHILD_GUTTER;
return { x, y };
}
/**
* Minimum parent size that still fits `childCount` uniformly-sized
* children. Leaf-slot variant kept for back-compat with callers that
* don't have per-child sizes (bumpZOrder, arrangeChildren).
*/
export function parentMinSize(childCount: number): { width: number; height: number } {
if (childCount <= 0) {
return { width: 210, height: 120 };
}
const cols = Math.min(GRID_COLS, childCount);
const rows = Math.ceil(childCount / cols);
const width =
PARENT_SIDE_PADDING * 2 +
cols * CHILD_DEFAULT_WIDTH +
(cols - 1) * CHILD_GUTTER;
const height =
PARENT_HEADER_PADDING +
rows * CHILD_DEFAULT_HEIGHT +
(rows - 1) * CHILD_GUTTER +
PARENT_BOTTOM_PADDING;
return { width, height };
}
/**
* Minimum parent size that fits a set of (possibly non-uniform)
* children. Uniform column width, per-row max height matches the
* geometry produced by `childSlotInGrid`. Used when a parent has
* grandchildren and a leaf-slot-sized grid can't hold the real,
* bigger nested cards.
*/
export function parentMinSizeFromChildren(children: NodeSize[]): NodeSize {
if (children.length === 0) return { width: 210, height: 120 };
const cols = Math.min(GRID_COLS, children.length);
const rows = Math.ceil(children.length / cols);
const colW = colWidthOf(children);
const rowHs = rowHeightsOf(children, cols);
const totalRowH = rowHs.reduce((a, b) => a + b, 0);
return {
width: PARENT_SIDE_PADDING * 2 + colW * cols + CHILD_GUTTER * (cols - 1),
height:
PARENT_HEADER_PADDING +
totalRowH +
CHILD_GUTTER * (rows - 1) +
PARENT_BOTTOM_PADDING,
};
}
/**
* Computes auto-layout positions for workspaces that have no persisted position
* (x === 0 AND y === 0). Workspaces with an existing non-zero position are used
@ -109,6 +279,14 @@ export function computeAutoLayout(
* Converts raw workspace data from the API into React Flow nodes and edges.
* Accepts an optional layoutOverrides map (from computeAutoLayout) to override
* positions for workspaces that were at 0,0.
*
* Parent/child rendering model: every workspace is a first-class React Flow
* node (full card). When a workspace has parent_id set, its RF `parentId` is
* set to the parent's id and its position is stored RELATIVE to the parent
* origin React Flow renders the child inside the parent's coordinate space,
* so moving the parent automatically moves all children. The DB keeps
* absolute x/y; the absrel conversion happens here on load, and the
* reverse translation happens in savePosition.
*/
export function buildNodesAndEdges(
workspaces: WorkspaceData[],
@ -117,16 +295,172 @@ export function buildNodesAndEdges(
nodes: Node<WorkspaceNodeData>[];
edges: Edge[];
} {
// All workspaces become nodes (children are rendered inside parent via WorkspaceNode)
const nodes: Node<WorkspaceNodeData>[] = workspaces.map((ws) => {
const override = layoutOverrides.get(ws.id);
const x = override?.x ?? ws.x;
const y = override?.y ?? ws.y;
return {
// React Flow requires parent nodes to appear before children in the nodes
// array. Topological-sort by depth-first walk from roots so children come
// after their parent regardless of the order the API returned them.
const byId = new Map(workspaces.map((w) => [w.id, w]));
const visited = new Set<string>();
const sorted: WorkspaceData[] = [];
function visit(ws: WorkspaceData) {
if (visited.has(ws.id)) return;
if (ws.parent_id && byId.has(ws.parent_id) && !visited.has(ws.parent_id)) {
visit(byId.get(ws.parent_id)!);
}
visited.add(ws.id);
sorted.push(ws);
}
workspaces.forEach(visit);
// Resolve each workspace's absolute position (apply layout override if any).
const absPos = new Map<string, { x: number; y: number }>();
for (const ws of workspaces) {
const o = layoutOverrides.get(ws.id);
absPos.set(ws.id, { x: o?.x ?? ws.x, y: o?.y ?? ws.y });
}
// Count children per parent so we can size parents to fit their team
// before any runtime measurement comes back.
const childCounts = new Map<string, number>();
for (const ws of workspaces) {
if (ws.parent_id) {
childCounts.set(ws.parent_id, (childCounts.get(ws.parent_id) ?? 0) + 1);
}
}
// Index direct children per parent for post-order subtree sizing.
// We walk `sorted` in REVERSE (post-order — children first) so
// subtreeSize[parent] sees its grandchildren-inclusive sizes via the
// already-computed subtreeSize[child].
const childrenByParent = new Map<string, WorkspaceData[]>();
for (const ws of workspaces) {
if (ws.parent_id && byId.has(ws.parent_id)) {
const arr = childrenByParent.get(ws.parent_id) ?? [];
arr.push(ws);
childrenByParent.set(ws.parent_id, arr);
}
}
const subtreeSize = new Map<string, NodeSize>();
for (let i = sorted.length - 1; i >= 0; i--) {
const ws = sorted[i];
const kids = childrenByParent.get(ws.id) ?? [];
if (kids.length === 0 || ws.collapsed) {
subtreeSize.set(ws.id, { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT });
} else {
const kidSizes = kids.map((k) =>
subtreeSize.get(k.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
subtreeSize.set(ws.id, parentMinSizeFromChildren(kidSizes));
}
}
// Track each parent's initial size so we can reset children that land
// outside those bounds. Parents without children fall back to the leaf
// default; parents with children get the grid-derived minimum — which
// now accounts for grandchildren via subtreeSize, so a nested parent
// no longer overflows its slot.
const parentSize = new Map<string, { width: number; height: number }>();
for (const ws of workspaces) {
// Reuse subtreeSize — it already accounts for nested grandchildren.
parentSize.set(
ws.id,
subtreeSize.get(ws.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
}
// Running index of children already placed per parent — used to hand
// out default grid slots for children whose stored position is outside
// the parent's computed box.
const nextChildIndex = new Map<string, number>();
// Depth per node so children always render above parents (and above
// parent's root-level siblings). React Flow uses a flat zIndex, so a
// child inherits zIndex = parent.zIndex + 1 — xyflow issue #4012.
const depthById = new Map<string, number>();
for (const ws of sorted) {
const d = ws.parent_id ? (depthById.get(ws.parent_id) ?? 0) + 1 : 0;
depthById.set(ws.id, d);
}
// Mark each node as hidden if any ancestor is collapsed. Walk from
// the root so children inherit the flag efficiently. (Parents stay
// visible; only descendants are hidden so the parent renders as a
// compact header-only card.)
const hiddenById = new Map<string, boolean>();
for (const ws of sorted) {
if (!ws.parent_id) {
hiddenById.set(ws.id, false);
continue;
}
const parent = byId.get(ws.parent_id);
const parentHidden = hiddenById.get(ws.parent_id) ?? false;
hiddenById.set(ws.id, parentHidden || !!parent?.collapsed);
}
const nodes: Node<WorkspaceNodeData>[] = sorted.map((ws) => {
const abs = absPos.get(ws.id)!;
const hasParent = !!ws.parent_id && byId.has(ws.parent_id);
let position = abs;
if (hasParent) {
const pa = absPos.get(ws.parent_id!)!;
position = { x: abs.x - pa.x, y: abs.y - pa.y };
// Auto-rescue on load: fires only when the child's bounding box
// is FULLY outside the parent's computed bbox by at least one
// child-width/height. Two real failure modes this covers:
//
// - Legacy data: a child whose stored absolute coords predate
// the nesting assignment, so abs→rel produces a huge offset
// far past any parent edge.
// - Corrupt org-imports with positions in a different
// coordinate space.
//
// Rejected heuristics we deliberately avoid:
// - `position.x < 0` alone — catches legitimate drift when the
// user drags the parent past a child that had small positive
// stored coords (child's relative goes mildly negative, but
// the layout is still recoverable).
// - Raw magnitude like `> 8000` — doesn't scale with parent
// size; a user who resized the parent huge could legitimately
// place a child at 9000px.
//
// Children slightly past the initial min-size (user had resized
// the parent larger on a previous session) are NEVER rescued —
// the bbox-overlap test gives them room. The manual "Arrange
// Children" context command is still the escape hatch for that
// bucket of data.
// Pure bbox-overlap test — self-calibrating without a magic
// margin. Rescue iff the child's bbox has ZERO overlap with the
// parent's bbox (the child would render completely detached).
// drift case (position.x = -40, CHILD_WIDTH = 260):
// child.right = 220, overlaps parent.left = 0 → kept
// screenshot case (position.x = -420, CHILD_WIDTH = 260):
// child.right = -160, doesn't overlap parent.left = 0 → rescued
// user resized larger (parent.width now 800, position.x = 500):
// child.left = 500 < parent.right = 800 → overlaps → kept
// legacy huge positive (position.x = 50000):
// child.left = 50000 >= parent.right → no overlap → rescued
const psize = parentSize.get(ws.parent_id!)!;
const myW = subtreeSize.get(ws.id)?.width ?? CHILD_DEFAULT_WIDTH;
const myH = subtreeSize.get(ws.id)?.height ?? CHILD_DEFAULT_HEIGHT;
const overlapsX =
position.x + myW > 0 && position.x < psize.width;
const overlapsY =
position.y + myH > 0 && position.y < psize.height;
if (!overlapsX || !overlapsY) {
const idx = nextChildIndex.get(ws.parent_id!) ?? 0;
nextChildIndex.set(ws.parent_id!, idx + 1);
// Use sibling-size-aware grid so a nested parent doesn't collide
// with a leaf sibling in the next row.
const siblings = (childrenByParent.get(ws.parent_id!) ?? []).map(
(c) => subtreeSize.get(c.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
position = childSlotInGrid(idx, siblings);
}
}
const node: Node<WorkspaceNodeData> = {
id: ws.id,
type: "workspaceNode",
position: { x, y },
// Don't set React Flow parentId — children render embedded inside the WorkspaceNode component
position,
data: {
name: ws.name,
status: ws.status,
@ -145,13 +479,46 @@ export function buildNodesAndEdges(
budgetLimit: ws.budget_limit ?? null,
budgetUsed: ws.budget_used ?? null,
},
// Hide child nodes from canvas — they render inside the parent WorkspaceNode
hidden: !!ws.parent_id,
};
if (hasParent) {
// React Flow native parent binding: children render inside parent's
// coordinate space and move with the parent. No `extent: 'parent'` —
// the user can drag a child out to un-nest (handled in Canvas.tsx
// onNodeDragStop with a bbox hit test).
node.parentId = ws.parent_id!;
}
// Stack children above their ancestors (xyflow #4012).
node.zIndex = depthById.get(ws.id) ?? 0;
// Collapse: descendants of a collapsed parent get hidden so the
// parent renders as a compact header-only card.
if (hiddenById.get(ws.id)) {
node.hidden = true;
}
// Seed every node with an explicit starting size so the initial
// grid layout is stable before React Flow has measured the DOM.
// - Parents (has children, not collapsed): sized to fit the
// child grid via parentMinSize so children don't render
// outside the bounds on first paint.
// - Collapsed parents: leaf-sized (header-only card).
// - Leaves: leaf-sized — they land in their grid slot cleanly.
//
// NodeResizer still drives user-initiated growth at runtime; these
// are only the initial values, and React Flow updates them in place
// when the user drags a resize handle. A future hydrate() will
// reset to the default until we persist width/height server-side.
const kids = childCounts.get(ws.id) ?? 0;
if (kids > 0 && !ws.collapsed) {
const size = parentSize.get(ws.id)!;
node.width = size.width;
node.height = size.height;
} else {
node.width = CHILD_DEFAULT_WIDTH;
node.height = CHILD_DEFAULT_HEIGHT;
}
return node;
});
// No parent→child edges — children are embedded inside the parent node.
// Only create edges between siblings or cross-team connections if needed in future.
// Edges stay empty — the visual parent/child cue is the enclosing card.
const edges: Edge[] = [];
return { nodes, edges };

View File

@ -6,9 +6,68 @@ import {
type NodeChange,
} from "@xyflow/react";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
import type { WorkspaceData, WSMessage } from "./socket";
import { handleCanvasEvent } from "./canvas-events";
import { buildNodesAndEdges, computeAutoLayout } from "./canvas-topology";
import {
buildNodesAndEdges,
computeAutoLayout,
defaultChildSlot,
parentMinSizeFromChildren,
sortParentsBeforeChildren,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
PARENT_BOTTOM_PADDING,
PARENT_SIDE_PADDING,
} from "./canvas-topology";
/**
* Walk every parent node and bump its width/height (if explicitly set)
* so the union of its children's relative bboxes plus padding fits. A
* parent's size never shrinks via this path only grows because
* shrinking on resize would fight the user's own NodeResizer drag.
*/
function growParentsToFitChildren<T extends Record<string, unknown>>(
nodes: Node<T>[],
): Node<T>[] {
// Index children by parentId so the scan is O(n).
const childrenByParent = new Map<string, Node<T>[]>();
for (const n of nodes) {
if (!n.parentId) continue;
const arr = childrenByParent.get(n.parentId) ?? [];
arr.push(n);
childrenByParent.set(n.parentId, arr);
}
let changed = false;
const out = nodes.map((n) => {
const kids = childrenByParent.get(n.id);
if (!kids || kids.length === 0) return n;
// Collapsed parents intentionally render compact — skip the grow
// pass so their size isn't pushed back out by their hidden kids.
const nData = n.data as unknown as WorkspaceNodeData | undefined;
if (nData?.collapsed) return n;
let maxRight = 0;
let maxBottom = 0;
for (const k of kids) {
const w = (k.measured?.width ?? k.width ?? CHILD_DEFAULT_WIDTH) as number;
const h = (k.measured?.height ?? k.height ?? CHILD_DEFAULT_HEIGHT) as number;
maxRight = Math.max(maxRight, k.position.x + w);
maxBottom = Math.max(maxBottom, k.position.y + h);
}
const requiredW = maxRight + PARENT_SIDE_PADDING;
const requiredH = maxBottom + PARENT_BOTTOM_PADDING;
const currentW = (n.measured?.width ?? n.width ?? 0) as number;
const currentH = (n.measured?.height ?? n.height ?? 0) as number;
if (requiredW <= currentW && requiredH <= currentH) return n;
changed = true;
return {
...n,
width: Math.max(currentW, requiredW),
height: Math.max(currentH, requiredH),
};
});
return changed ? out : nodes;
}
// Re-export extracted types and functions so existing imports from "@/store/canvas" keep working
export { summarizeWorkspaceCapabilities } from "./canvas-capabilities";
@ -51,6 +110,18 @@ interface CanvasState {
panelTab: PanelTab;
dragOverNodeId: string | null;
contextMenu: ContextMenuState | null;
// Live width of the SidePanel in pixels. Only meaningful when
// selectedNodeId is non-null (panel visible). The Toolbar reads this
// to stay centred on the remaining canvas area instead of the full
// viewport, so the "Audit" / "Search" / "Settings" buttons don't get
// hidden behind the panel when a workspace is selected.
sidePanelWidth: number;
setSidePanelWidth: (w: number) => void;
// Whether the TemplatePalette left-drawer is open. Consumed by the
// Legend so it can shift right and avoid being hidden under the
// palette. Set by TemplatePalette's toggle button.
templatePaletteOpen: boolean;
setTemplatePaletteOpen: (open: boolean) => void;
hydrate: (workspaces: WorkspaceData[]) => void;
applyEvent: (msg: WSMessage) => void;
onNodesChange: (changes: NodeChange<Node<WorkspaceNodeData>>[]) => void;
@ -64,6 +135,28 @@ interface CanvasState {
setDragOverNode: (id: string | null) => void;
nestNode: (draggedId: string, targetId: string | null) => Promise<void>;
isDescendant: (ancestorId: string, nodeId: string) => boolean;
/** Re-order siblings in z-index space. `direction = +1` sends the node
* one step forward among its parent's children (or among canvas
* roots); -1 sends it one step back. Figma Cmd+]/[ parity. */
bumpZOrder: (nodeId: string, direction: 1 | -1) => void;
/** Re-parent many nodes at once, preserving each node's absolute
* position. Lucidchart pattern: drag a selection into a frame and
* the inter-node layout stays intact. Used when the primary dragged
* node of a multi-select drag triggers a nest confirmation. */
batchNest: (nodeIds: string[], targetId: string | null) => Promise<void>;
/** Run the parent auto-grow pass once. Canvas.onNodeDragStop calls
* this so a drag that pushed a child past the parent edge commits
* the parent grow on release (commit-on-release pattern). */
growParentsToFitChildren: () => void;
/** Re-layout a parent's children to the default 2-column grid. Used
* by the "Arrange children" context-menu command so users can rescue
* out-of-bounds children on demand topology no longer does it
* automatically (P3.12 opt-in rescue). */
arrangeChildren: (parentId: string) => void;
/** Toggle the collapsed flag on a parent and hide/show every
* descendant so the card renders as a compact header-only frame.
* Miro "frame outline view" analog. */
setCollapsed: (parentId: string, collapsed: boolean) => void;
openContextMenu: (menu: ContextMenuState) => void;
closeContextMenu: () => void;
// Pending delete confirmation — lives in the store (not inside ContextMenu's
@ -115,6 +208,10 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
sidePanelWidth: 480, // matches SIDEPANEL_DEFAULT_WIDTH in SidePanel.tsx
setSidePanelWidth: (w) => set({ sidePanelWidth: w }),
templatePaletteOpen: false,
setTemplatePaletteOpen: (open) => set({ templatePaletteOpen: open }),
// Batch selection
selectedNodeIds: new Set<string>(),
toggleNodeSelection: (id) => {
@ -230,6 +327,256 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
setPanelTab: (tab) => set({ panelTab: tab }),
setDragOverNode: (id) => set({ dragOverNodeId: id }),
batchNest: async (nodeIds, targetId) => {
if (nodeIds.length === 0) return;
// Selection-roots filter: if the user selected both A and A's
// descendant B and dragged the pair into T, the intent is "move
// the subtree" — B should stay under A, not become a sibling of
// A under T. Drop every selected node whose ancestor is also
// selected; those will follow their ancestor via React Flow's
// parent-of binding automatically.
const selectedSet = new Set(nodeIds);
const { nodes: before, edges: beforeEdges } = get();
const byId = new Map(before.map((n) => [n.id, n]));
const rootsOnly: string[] = [];
for (const id of nodeIds) {
let cursor = byId.get(id)?.data.parentId ?? null;
let hasSelectedAncestor = false;
// Seen-set guards against a corrupt parentId cycle. Shouldn't
// happen with a healthy backend — nestNode itself blocks cycles
// via isDescendant — but this walk is user-triggered and the
// cost of the guard is one set allocation per selected node.
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
if (selectedSet.has(cursor)) {
hasSelectedAncestor = true;
break;
}
cursor = byId.get(cursor)?.data.parentId ?? null;
}
if (!hasSelectedAncestor) rootsOnly.push(id);
}
if (rootsOnly.length === 0) return;
if (rootsOnly.length === 1) {
await get().nestNode(rootsOnly[0], targetId);
return;
}
// Batch path: do all state math against one snapshot so every
// selected node sees the same "before" world, commit one set(),
// then fire every PATCH in parallel. Previously this called
// nestNode sequentially, which cost 2N round-trips (parent_id +
// x/y) strictly serialized; now it's 1 round-trip per node, all
// in flight at once. For a typical 3-5 node selection on a
// ~200ms link this drops the perceived re-parent latency from
// ~2s to ~200ms.
const absOf = (id: string | null | undefined): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const depthOf = (id: string | null | undefined): number => {
let d = 0;
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
cursor = n.data.parentId;
d += 1;
}
return d;
};
const newParentAbs = absOf(targetId);
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
interface Plan {
id: string;
newRelative: { x: number; y: number };
draggedAbs: { x: number; y: number };
depthDelta: number;
}
const plan: Plan[] = [];
const movedIds = new Set<string>();
// Filter out nodes that would be invalid targets / no-ops.
for (const id of rootsOnly) {
const dragged = byId.get(id);
if (!dragged) continue;
const currentParentId = dragged.data.parentId;
if (currentParentId === targetId) continue;
// Can't nest into yourself or your own descendant.
if (targetId && get().isDescendant(id, targetId)) continue;
const oldParentAbs = absOf(currentParentId);
const draggedAbs = {
x: dragged.position.x + oldParentAbs.x,
y: dragged.position.y + oldParentAbs.y,
};
const newRelative = {
x: draggedAbs.x - newParentAbs.x,
y: draggedAbs.y - newParentAbs.y,
};
const oldOwnDepth =
dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
plan.push({
id,
newRelative,
draggedAbs,
depthDelta: newOwnDepth - oldOwnDepth,
});
movedIds.add(id);
// Every descendant of a moved node also shifts by the same delta
// so grandchildren don't fall behind their re-parented ancestor.
const bfs = [id];
while (bfs.length) {
const head = bfs.shift()!;
for (const n of before) {
if (n.data.parentId === head && !movedIds.has(n.id)) {
movedIds.add(n.id);
bfs.push(n.id);
}
}
}
}
if (plan.length === 0) return;
const planById = new Map(plan.map((p) => [p.id, p]));
// One optimistic set() covers every re-parent + every descendant
// zIndex shift; no further state mutations before the PATCHes come
// back (failed PATCHes roll back individual nodes below).
set({
nodes: before.map((n) => {
const p = planById.get(n.id);
if (p) {
return {
...n,
position: p.newRelative,
parentId: targetId ?? undefined,
zIndex: newOwnDepth,
data: { ...n.data, parentId: targetId },
};
}
// Descendant of a moved node — shift zIndex only. Find the
// nearest ancestor in `plan` (walking up parents) to know
// which depthDelta applies.
if (movedIds.has(n.id)) {
let cursor: string | null | undefined = n.data.parentId;
while (cursor) {
const anc = planById.get(cursor);
if (anc) {
if (anc.depthDelta === 0) break;
return { ...n, zIndex: (n.zIndex ?? 0) + anc.depthDelta };
}
cursor = byId.get(cursor)?.data.parentId ?? null;
}
return n;
}
return n;
}),
edges: beforeEdges.filter(
(e) => !movedIds.has(e.source) && !movedIds.has(e.target),
),
});
// Keep parents before children in the array (same invariant
// nestNode enforces). Needed after multi-select re-parent because
// the selection order is user-driven.
set({ nodes: sortParentsBeforeChildren(get().nodes) });
// Fire every PATCH in parallel. Individual failures roll back just
// that node (others remain committed, matching the single-node
// rollback behaviour in nestNode).
const results = await Promise.allSettled(
plan.map((p) =>
api.patch(`/workspaces/${p.id}`, {
parent_id: targetId,
x: p.draggedAbs.x,
y: p.draggedAbs.y,
}),
),
);
const rolledBack: string[] = [];
for (let i = 0; i < results.length; i++) {
if (results[i].status === "rejected") rolledBack.push(plan[i].id);
}
if (rolledBack.length > 0) {
const rollbackSet = new Set(rolledBack);
set({
nodes: get().nodes.map((n) => {
if (!rollbackSet.has(n.id)) return n;
const original = byId.get(n.id);
if (!original) return n;
return {
...n,
position: original.position,
parentId: original.parentId,
zIndex: original.zIndex,
data: { ...n.data, parentId: original.data.parentId },
};
}),
});
// Surface the partial failure — silent rollback would otherwise
// leave the canvas in a state the user can't explain ("I dragged
// 5 cards, 3 moved and 2 snapped back?"). Cap the name list so a
// 50-node partial failure doesn't overflow the toast container.
const NAMES_IN_TOAST = 3;
const names = rolledBack
.map((id) => byId.get(id)?.data.name)
.filter((n): n is string => Boolean(n));
const shown = names.slice(0, NAMES_IN_TOAST).join(", ");
const overflow = names.length - NAMES_IN_TOAST;
const listFragment = shown
? overflow > 0
? `: ${shown} and ${overflow} more`
: `: ${shown}`
: "";
showToast(
`Could not re-parent ${rolledBack.length} of ${plan.length} workspace${plan.length === 1 ? "" : "s"}${listFragment}`,
"error",
);
}
},
bumpZOrder: (nodeId, direction) => {
const { nodes } = get();
const target = nodes.find((n) => n.id === nodeId);
if (!target) return;
// Siblings share parentId; re-rank them by their current zIndex (then
// insertion order) so we can SWAP the target with its neighbour in
// the bump direction rather than drifting zIndex up/down unbounded.
// This keeps sibling zIndex values within `[baseDepth, baseDepth+N)`,
// which is what findDropTarget's tiebreakers assume.
const siblings = nodes
.filter((n) => n.data.parentId === target.data.parentId)
.slice()
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
if (siblings.length < 2) return;
const idx = siblings.findIndex((n) => n.id === nodeId);
const neighbourIdx = idx + direction;
if (neighbourIdx < 0 || neighbourIdx >= siblings.length) return;
const neighbour = siblings[neighbourIdx];
const targetZ = target.zIndex ?? 0;
const neighbourZ = neighbour.zIndex ?? 0;
// Ensure a visible swap even when both had identical zIndex (fresh
// topology: every sibling starts at zIndex=depth). Nudge the
// neighbour one step the other way so the pair stays adjacent.
const resolvedTargetZ = targetZ === neighbourZ ? targetZ + direction : neighbourZ;
const resolvedNeighbourZ = targetZ === neighbourZ ? targetZ : targetZ;
set({
nodes: nodes.map((n) => {
if (n.id === nodeId) return { ...n, zIndex: resolvedTargetZ };
if (n.id === neighbour.id) return { ...n, zIndex: resolvedNeighbourZ };
return n;
}),
});
},
isDescendant: (ancestorId, nodeId) => {
const { nodes } = get();
let current = nodes.find((n) => n.id === nodeId);
@ -242,46 +589,136 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
nestNode: async (draggedId, targetId) => {
const { nodes, edges } = get();
const currentParentId = nodes.find((n) => n.id === draggedId)?.data.parentId ?? null;
// No change needed
const dragged = nodes.find((n) => n.id === draggedId);
if (!dragged) return;
const currentParentId = dragged.data.parentId;
if (currentParentId === targetId) return;
// Optimistic update:
// - Set parentId in data
// - Hide child nodes (they render inside parent WorkspaceNode)
// - Remove all edges involving the dragged node
// Compute each ancestor's absolute position by walking up the
// parentId chain. We need this to translate the dragged node's
// `position` (relative to its current parent when nested) between
// the old and new coordinate spaces so the card doesn't visually
// jump on nest/unnest.
const absOf = (id: string | null): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const oldParentAbs = absOf(currentParentId);
const newParentAbs = absOf(targetId);
const draggedAbs = {
x: dragged.position.x + oldParentAbs.x,
y: dragged.position.y + oldParentAbs.y,
};
const newRelative = {
x: draggedAbs.x - newParentAbs.x,
y: draggedAbs.y - newParentAbs.y,
};
const newEdges = edges.filter(
(e) => e.source !== draggedId && e.target !== draggedId
(e) => e.source !== draggedId && e.target !== draggedId,
);
// Depth walk so zIndex gets bumped correctly on nest/unnest
// (children render above their new ancestor chain). `depthOf(null)`
// returns 0; for any non-null cursor we count one hop per ancestor.
const depthOf = (id: string | null | undefined): number => {
let d = 0;
let cursor: string | null | undefined = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
cursor = n.data.parentId;
d += 1;
}
return d;
};
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
const oldOwnDepth = dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
const depthDelta = newOwnDepth - oldOwnDepth;
// Collect every descendant of the dragged node so we can shift their
// zIndex by the same depthDelta — otherwise grandchildren stay at
// their old depth zIndex after the move and render below ancestors
// they just joined. BFS to avoid stack surprises on deep hierarchies.
const movedIds = new Set<string>([draggedId]);
const bfsQueue = [draggedId];
while (bfsQueue.length) {
const head = bfsQueue.shift()!;
for (const n of nodes) {
if (n.data.parentId === head && !movedIds.has(n.id)) {
movedIds.add(n.id);
bfsQueue.push(n.id);
}
}
}
// When a child leaves its parent, clear the parent's explicit
// width/height. growParentsToFitChildren is grow-only so it can't
// shrink on its own; without this, a parent that auto-grew to
// contain the dragged child stays at that size after un-nest,
// leaving a large empty frame. React Flow then measures the new
// size from the card's own min-width/min-height CSS.
const shrinkOldParent = !!currentParentId && targetId !== currentParentId;
set({
nodes: nodes.map((n) =>
n.id === draggedId
? {
...n,
hidden: !!targetId, // Hide if becoming a child, show if un-nesting
data: { ...n.data, parentId: targetId },
}
: n
),
nodes: nodes.map((n) => {
if (n.id === draggedId) {
return {
...n,
position: newRelative,
parentId: targetId ?? undefined,
zIndex: newOwnDepth,
data: { ...n.data, parentId: targetId },
};
}
if (shrinkOldParent && n.id === currentParentId) {
const { width: _w, height: _h, ...rest } = n;
void _w; void _h;
return rest as typeof n;
}
if (movedIds.has(n.id) && depthDelta !== 0) {
return { ...n, zIndex: (n.zIndex ?? 0) + depthDelta };
}
return n;
}),
edges: newEdges,
});
// React Flow requires parents before children in the array. Without
// this re-sort a newly-nested child can end up ahead of its new
// parent, which makes RF log "Parent node not found" and render the
// child at canvas-absolute coords (far outside the parent, which
// is the flash-bug the user just flagged).
set({ nodes: sortParentsBeforeChildren(get().nodes) });
// Persist to API
try {
await api.patch(`/workspaces/${draggedId}`, { parent_id: targetId });
// One round-trip per nest: the /workspaces/:id PATCH handler
// accepts parent_id + x + y in a single body. The absolute x/y
// is what the DB stores as canonical (matches savePosition
// elsewhere), so reload renders the same place regardless of
// which parent the child was under at save time.
await api.patch(`/workspaces/${draggedId}`, {
parent_id: targetId,
x: draggedAbs.x,
y: draggedAbs.y,
});
} catch {
// Revert on failure
set({
nodes: get().nodes.map((n) =>
n.id === draggedId
? {
...n,
hidden: !!currentParentId,
position: dragged.position,
parentId: currentParentId ?? undefined,
data: { ...n.data, parentId: currentParentId },
}
: n
: n,
),
edges,
});
@ -309,7 +746,10 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
removeNode: (id) => {
const { nodes, edges, selectedNodeId } = get();
// Re-parent children to the deleted node's parent (or root)
// Re-parent children to the deleted node's parent (or root).
// Children are first-class RF nodes now — we just re-point their
// parentId (both RF's native field and our data mirror). No hidden
// flag is toggled because cards are always visible.
const deletedNode = nodes.find((n) => n.id === id);
const parentOfDeleted = deletedNode?.data.parentId ?? null;
set({
@ -319,7 +759,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
n.data.parentId === id
? {
...n,
hidden: !!parentOfDeleted,
parentId: parentOfDeleted ?? undefined,
data: { ...n.data, parentId: parentOfDeleted },
}
: n
@ -343,9 +783,143 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
const next = applyNodeChanges(changes, get().nodes);
// Parent auto-grow is intentionally conservative. Running
// growParentsToFitChildren on every change (including the dozens of
// position updates emitted during a single drag) caused the
// "edge-chase" artifact tldraw documented — as the parent grows in
// response to the child near its edge, the child's relative
// position becomes valid again and the grow stops mid-drag, only to
// resume on the next tick. Commit-on-release: only run grow when a
// change set contains a `dimensions` change (NodeResizer commit),
// not on pure `position` changes. Drag-stop grow is handled
// explicitly in Canvas.onNodeDragStop via growOnce().
const hasDimensionChange = changes.some((c) => c.type === "dimensions");
set({ nodes: hasDimensionChange ? growParentsToFitChildren(next) : next });
},
growParentsToFitChildren: () => {
set({ nodes: growParentsToFitChildren(get().nodes) });
},
setCollapsed: (parentId, collapsed) => {
const { nodes } = get();
// Step 1 — apply the new collapsed flag on the target.
const updatedCollapsed = new Map<string, boolean>();
for (const n of nodes) {
updatedCollapsed.set(
n.id,
n.id === parentId ? collapsed : !!n.data.collapsed,
);
}
// Step 2 — index children once so the visibility pass is O(n), not
// O(n·d). Walk roots downward, inheriting `hiddenBecauseAncestor`
// so a node is hidden iff ANY ancestor in the chain is collapsed.
// This matches canvas-topology.buildNodesAndEdges so setCollapsed
// and hydrate produce identical node.hidden flags — no drift when
// the server pushes a fresh snapshot mid-session.
const childrenByParent = new Map<string | null, string[]>();
for (const n of nodes) {
const p = n.data.parentId ?? null;
const arr = childrenByParent.get(p) ?? [];
arr.push(n.id);
childrenByParent.set(p, arr);
}
const hiddenById = new Map<string, boolean>();
const stack: Array<{ id: string; hidden: boolean }> = (
childrenByParent.get(null) ?? []
).map((id) => ({ id, hidden: false }));
while (stack.length) {
const { id, hidden } = stack.pop()!;
hiddenById.set(id, hidden);
const isCollapsed = updatedCollapsed.get(id) ?? false;
for (const childId of childrenByParent.get(id) ?? []) {
stack.push({ id: childId, hidden: hidden || isCollapsed });
}
}
// Expanded size must fit the target's ACTUAL children, including
// any nested-parent children that are themselves oversized. Using a
// leaf-count formula (parentMinSize) would undersize the parent
// whenever a child was itself a team — e.g. CTO expanding to show
// Dev Lead (which carries 6 engineers) would render Dev Lead
// clipped. Read each direct child's current width/height from the
// node itself; those already reflect the subtree sizing computed
// in buildNodesAndEdges.
const directChildIds = childrenByParent.get(parentId) ?? [];
const childSizes = directChildIds.map((cid) => {
const cn = nodes.find((n) => n.id === cid);
return {
width: (cn?.width as number | undefined) ?? CHILD_DEFAULT_WIDTH,
height: (cn?.height as number | undefined) ?? CHILD_DEFAULT_HEIGHT,
};
});
const expandedSize = parentMinSizeFromChildren(childSizes);
set({
nodes: nodes.map((n) => {
const isTarget = n.id === parentId;
const nextHidden = hiddenById.get(n.id) ?? false;
if (!isTarget && n.hidden === nextHidden) return n;
if (!isTarget) {
return { ...n, hidden: nextHidden };
}
// Target parent: update collapsed flag + size. Dropping width/
// height would leave the node at its prior (possibly huge)
// dimensions after a collapse, leaving a gigantic empty card
// with no visible children.
return {
...n,
hidden: nextHidden,
data: { ...n.data, collapsed },
width: collapsed ? CHILD_DEFAULT_WIDTH : expandedSize.width,
height: collapsed ? CHILD_DEFAULT_HEIGHT : expandedSize.height,
};
}),
});
},
arrangeChildren: (parentId) => {
const { nodes } = get();
const kids = nodes
.filter((n) => n.parentId === parentId)
.sort((a, b) => (a.data.name || "").localeCompare(b.data.name || ""));
if (kids.length === 0) return;
const slotByKid = new Map<string, { x: number; y: number }>();
kids.forEach((k, i) => slotByKid.set(k.id, defaultChildSlot(i)));
// Absolute position of the parent, walking the full ancestor chain.
// Required for a correct PATCH payload when the parent itself is
// nested — `parent.position` is RELATIVE to its own parent, so a
// naive `slot + parent.position` would store parent-local coords
// as if they were absolute and corrupt the workspace on reload.
const absOf = (id: string | null | undefined): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null | undefined = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const parentAbs = absOf(parentId);
set({
nodes: nodes.map((n) => {
const slot = slotByKid.get(n.id);
return slot ? { ...n, position: slot } : n;
}),
});
for (const k of kids) {
const slot = slotByKid.get(k.id)!;
const absX = slot.x + parentAbs.x;
const absY = slot.y + parentAbs.y;
api.patch(`/workspaces/${k.id}`, { x: absX, y: absY }).catch((e) => {
console.warn(`arrangeChildren: failed to persist position for ${k.id}`, e);
});
}
},
savePosition: async (nodeId: string, x: number, y: number) => {

View File

@ -1 +0,0 @@
{"body": "## Demo Complete \u2014 #1172 AGENTS.md Auto-Generation\n\nAll acceptance criteria met \u2705\n\n### What was built\n\nA working demo + screencast spec for the AAIF / Linux Foundation AGENTS.md standard.\n\n**Demo files:**\n- `marketing/demos/agents-md-auto-generation/README.md` \u2014 full working demo with 4 walkthrough scenarios\n- `marketing/demos/agents-md-auto-generation/narration.mp3` \u2014 30s TTS narration (en-US-AriaNeural)\n\n**Screencast outline (1 min):**\n1. Canvas: pm-agent + researcher online\n2. Terminal: researcher reads PM's AGENTS.md via platform files API\n3. AGENTS.md output \u2014 role, A2A endpoint, tools\n4. Researcher dispatches A2A task to PM using discovered endpoint\n5. Canvas shows both active \u2014 close on \"agents that can read each other\"\n\n### Repo link\n\n`workspace/agents_md.py` on `molecule-core` main\nDirect: `workspace/agents_md.py`\n\n### TTS narration script (30s)\n\n> When a PM agent starts up in Molecule AI, it generates an AGENTS.md file automatically \u2014 not manually written, not kept in sync by hand. It reflects the workspace config in real time. Any other agent can read it to discover what the PM does, how to reach it, and what tools it has. No system prompts, no guessing. Just the facts. That's the AAIF standard in action: agents that can read each other without human intervention. AGENTS.md auto-generation, from Molecule AI workspace.\n\n### Note\n\nPush pending on GH_TOKEN refresh \u2014 all files are on the `content/blog/memory-backup-restore` branch and ready.\n"}

View File

@ -1 +0,0 @@
{"body": "## Demo Complete \u2014 #1173 Cloudflare Artifacts Integration\n\nAll acceptance criteria met \u2705\n\n### What was built\n\nA working demo + screencast spec showing workspace snapshot storage and forking via Cloudflare Artifacts.\n\n**Demo files:**\n- `marketing/demos/cloudflare-artifacts/README.md` \u2014 full working demo with 5 walkthrough scenarios\n- `marketing/demos/cloudflare-artifacts/narration.mp3` \u2014 30s TTS narration (en-US-AriaNeural)\n\n**Screencast outline (1 min):**\n1. Canvas: workspace online\n2. Terminal: `POST /workspaces/:id/artifacts` \u2014 repo created, remote URL returned\n3. Mint git credential via `POST /workspaces/:id/artifacts/token` \u2014 `clone_url` shown\n4. `git clone` runs, agent writes snapshot, `git push` \u2014 push succeeds\n5. Fork call: `POST /workspaces/:id/artifacts/fork` \u2014 new repo created in CF Artifacts\n6. Close on \"versioned agent state, built into the platform\"\n\n### Repo link\n\n`workspace-server/internal/handlers/artifacts.go` on `molecule-core` main\nDirect: `workspace-server/internal/handlers/artifacts.go`\n\n### TTS narration script (30s)\n\n> Cloudflare Artifacts turns your Molecule AI workspace into a versioned git repository. Attach a repo, mint a short-lived credential, and the agent can push snapshots \u2014 memory dumps, task state, config \u2014 and other agents can fork the history to bootstrap from the same point. No external git service configuration. No separate dashboard. The platform manages the credential lifecycle and the repo link. Versioned agent state, built into the platform. That's the first-mover advantage: Git for agents, from Molecule AI.\n\n### Note\n\nPush pending on GH_TOKEN refresh \u2014 all files are on the `content/blog/memory-backup-restore` branch and ready.\n"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,354 @@
---
title: "The MCP Server List: Which Servers Work With Molecule AI?"
date: 2026-04-20
slug: mcp-server-list
description: "A practical guide to the Model Context Protocol ecosystem — finding the right MCP server for your use case, which ones integrate with Molecule AI, and how to evaluate servers before you commit."
tags: [mcp, model-context-protocol, ai-agents, integrations]
author: Molecule AI
og_title: "The MCP Server List: Which Servers Work With Molecule AI?"
og_description: "Find the right MCP server for your AI agent workflow. Full list of reference servers, official integrations, server frameworks, and community registries — with Molecule AI compatibility notes."
og_image: /assets/blog/2026-04-20-mcp-server-list/og.png
twitter_card: summary_large_image
canonical: https://molecule.ai/blog/mcp-server-list
keywords:
- MCP server list
- MCP servers
- Model Context Protocol
- MCP server
- MCP integration
---
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "The MCP Server List: Which Servers Work With Molecule AI?",
"datePublished": "2026-04-20",
"dateModified": "2026-04-21",
"author": { "@type": "Organization", "name": "Molecule AI" },
"publisher": {
"@type": "Organization",
"name": "Molecule AI",
"logo": { "@type": "ImageObject", "url": "https://molecule.ai/logo.png" }
},
"description": "A practical guide to the Model Context Protocol ecosystem — finding the right MCP server for your use case, which ones integrate with Molecule AI, and how to evaluate servers before you commit.",
"keywords": "MCP server list, MCP servers, Model Context Protocol, MCP server, MCP integration",
"url": "https://molecule.ai/blog/mcp-server-list"
}
</script>
# The MCP Server List: Which Servers Work With Molecule AI?
The [Model Context Protocol](/docs/guides/mcp-server-setup) (MCP) is an open standard that lets AI agents connect to external tools and data sources through a unified interface. Rather than writing custom code for every tool integration, MCP servers expose resources and tools that any compatible AI agent can discover and call. This MCP server list covers everything you need to find the right integration for your workflow.
Molecule AI supports MCP out of the box. This means any MCP server in the ecosystem — from lightweight reference implementations to enterprise-grade integrations — can be added to a Molecule AI agent with a server configuration. No forks, no wrappers, no compatibility layers required. This page is your practical MCP server list for real-world AI agent workflows.
This guide covers the full MCP server list that matters: reference servers from the MCP spec authors, official integrations from major vendors, server frameworks for building your own, and community-maintained registries where the broader MCP ecosystem publishes new MCP servers every week. Whether you need one MCP server or a stack of them, this MCP server list gives you the starting points for every major category.
---
## What Is an MCP Server?
An MCP server is a process that implements the Model Context Protocol. It runs separately from your AI agent and communicates over stdio or HTTP+SSE. When a compatible AI agent connects, it receives a manifest of available **tools**, **resources**, and **prompts** — no code changes on the agent side.
The MCP specification defines the transport layer and message shapes. The server implementer decides what capabilities to expose. This separation is what makes the MCP ecosystem portable: an MCP server written for one MCP-compatible platform works on any other, including Molecule AI.
The key MCP concepts that every server implements:
- **Tools** — functions the agent can call (e.g., `search_code`, `read_file`)
- **Resources** — data the agent can read (e.g., repository contents, database schemas)
- **Prompts** — reusable prompt templates the agent can load
Every MCP server in this list exposes at least one of these three primitives. Most expose tools; a well-designed MCP server also exposes resources. The Model Context Protocol makes all of this possible by providing a shared vocabulary and transport — so MCP servers and the agents that call them don't need to coordinate on anything beyond the protocol itself.
---
## MCP Reference Servers
The [modelcontextprotocol GitHub organization](https://github.com/modelcontextprotocol) maintains a set of reference server implementations. These are canonical examples maintained by the MCP spec authors and are often the best starting point for common integrations. This reference MCP server list is kept up to date with each protocol release.
### Filesystem MCP Server
Provides local file system access. Useful for AI agents that need to read project files, write output, or navigate a codebase.
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
}
}
}
```
**Molecule AI note:** Configure `allowedDirectories` in the server args to scope filesystem access. Use separate server configs per workspace if you need per-project isolation.
### Git MCP Server
Exposes Git operations — commit history, diffs, branch listings, file contents at any ref. Useful for AI agents doing code review or changelog generation.
```json
{
"mcpServers": {
"git": {
"command": "uvx",
"args": ["mcp-server-git", "--repository", "/path/to/repo"]
}
}
}
```
**Molecule AI note:** Pass the `--repository` flag to scope the server to a specific project. Without it, the server operates on whatever directory the process runs in.
### Memory MCP Server
A vector-backed memory server that persists facts across agent sessions using embeddings. The agent can store key-value facts and retrieve them semantically later.
```json
{
"mcpServers": {
"memory": {
"command": "node",
"args": ["/path/to/memory-server/dist/index.js"]
}
}
}
```
**Molecule AI note:** Combine with Molecule AI's built-in session context for hybrid short-term + long-term memory strategies.
### Brave Search MCP Server
Web search via the Brave Search API. Gives the agent real-time internet access for research tasks.
```json
{
"mcpServers": {
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "your-api-key"
}
}
}
}
```
**Molecule AI note:** Set the `BRAVE_API_KEY` as an environment variable in your Molecule AI workspace secrets, not in the server config file.
---
## Official MCP Integrations
Beyond the reference servers, several established products ship MCP-compatible servers. These are production-grade implementations maintained by the vendors. Each MCP integration in this section ships with vendor support and backward-compatibility guarantees.
### Slack MCP Integration
The official Slack SDK includes an MCP server that exposes channels, messages, and thread replies as tools and resources. An agent can post updates, read channel history, or monitor for specific events.
**Use cases:** Team status updates, incident channel posting, cross-team workflow automation.
### GitHub MCP Integration
The GitHub MCP server surfaces repositories, issues, pull requests, and discussions as structured resources. Agents can create issues, comment on PRs, or query code search.
**Use cases:** Automated code review summaries, issue triaging, release note generation.
### AWS KB Retrieval MCP Integration
Amazon's Bedrock Knowledge Bases can be accessed via MCP. Gives agents read access to indexed enterprise documents.
**Use cases:** Internal knowledge base queries, policy document retrieval, compliance checking.
### Google Drive MCP Integration
Read access to Google Drive files and folders. Agents can search documents, read sheet data, or pull slide content.
**Use cases:** Research synthesis from Drive documents, automated reporting from Sheets.
---
## MCP Server Frameworks
If you need a custom MCP integration not covered by existing servers, MCP server frameworks let you build one without implementing the Model Context Protocol from scratch. These frameworks handle the protocol boilerplate so you can focus on your tool's logic. Building your own MCP server is the right call when you have a proprietary data source, an internal API, or a domain-specific tool that isn't covered by the available MCP servers in the ecosystem.
### Python MCP SDK
The official Python implementation. Ideal for data-heavy or ML-adjacent integrations.
```python
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("my-analytics-server")
@server.list_tools()
async def list_tools():
return [
Tool(
name="query_analytics",
description="Run a query against the analytics database",
inputSchema={
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SQL query to execute"}
},
"required": ["sql"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "query_analytics":
result = run_query(arguments["sql"])
return [TextContent(type="text", text=str(result))]
```
**Molecule AI note:** Package your server as a Docker image and reference it by image URL in your Molecule AI workspace server config for one-command deployment.
### TypeScript MCP SDK
The official Node.js/TypeScript implementation. Best for web-service integrations, API wrappers, and real-time data sources.
### Go MCP Server Framework
A lightweight Go implementation for high-performance or infrastructure-level integrations.
---
## Community MCP Registries
The MCP ecosystem grows through community contributions. These registries index servers by category and are the best places to discover new MCP servers without searching GitHub manually. Bookmark these — the community publishes new MCP servers every week, and these registries stay current.
### awesome-mcp
The canonical community MCP server list. Maintained on GitHub with categorized entries for tools, resources, and prompt servers. Covers everything from production-grade MCP servers to experimental community projects. Start here when you know the category you need but not the specific MCP server.
### Model Context Protocol Registry (registry.mcp.so)
A structured registry that categorizes servers by domain: development, productivity, data, infrastructure. Each entry links to the implementation and documents supported MCP features.
### MCP Hub
A community-curated directory with install commands for each server. Particularly useful for quickly spinning up a new MCP server via `npx` or `uvx`.
---
## How to Install an MCP Server
The exact install steps depend on the server, but most MCP servers follow the same startup patterns. Most servers can be started with a single command:
```bash
# Via npx (Node.js servers)
npx -y @modelcontextprotocol/server-filesystem /allowed/path
# Via uvx (Python servers)
uvx mcp-server-git --repository /path/to/repo
# Via Docker (any server, in an isolated container)
docker run -v /data:/data my-registry/my-mcp-server --allowed-path /data
```
Once started, add the MCP server to your Molecule AI workspace configuration:
```json
{
"mcpServers": {
"my-server": {
"command": "docker",
"args": ["run", "--rm", "-v", "/data:/data", "my-registry/my-mcp-server", "--allowed-path", "/data"]
}
}
}
```
**Molecule AI note:** Use Docker-based servers for any MCP integration that requires credentials or filesystem access you don't want to co-locate with the agent process. Molecule AI's workspace isolation handles the container lifecycle automatically.
---
## Choosing the Right MCP Server: A Decision Guide
Not every MCP server belongs in every project. Here's how to evaluate which MCP servers to add to your Molecule AI workspace. The best MCP server is the one that does exactly what your agent needs — nothing more. Extra MCP servers add latency, credential surface, and maintenance burden without adding value.
| Need | Recommended MCP servers |
|------|------------------------|
| Read project files | filesystem |
| Git operations | git |
| Web search | brave-search |
| Slack/Teams integration | slack, teams |
| Cloud infrastructure queries | aws-kb, google-drive |
| Long-term memory | memory |
| Custom data source | Build with Python/TypeScript SDK |
**Start narrow.** Add MCP servers as your agent's tasks require them. Each MCP server is a new attack surface and a new failure mode. The Model Context Protocol gives you a consistent interface to manage them all — but you still need to evaluate each MCP server's security posture before adding it to a workspace. Molecule AI's workspace-level server configuration makes it easy to add servers incrementally and revoke access at the workspace boundary.
---
## MCP Server Governance With Molecule AI
Every MCP server your agent can access is a decision about what the agent is permitted to do. Molecule AI gives you controls at the workspace level so you can govern your MCP servers in production:
- **Server allowlisting** — configure exactly which servers can run in a workspace
- **Environment variable scoping** — API keys used by MCP servers stay in workspace secrets, not in config files
- **Audit logging** — every tool call made through an MCP server is recorded in the workspace activity log
- **Workspace isolation** — each workspace runs its server config independently, so one team's servers don't affect another's
This is the governance layer that makes running MCP servers practical in production. A list of MCP servers is only as useful as the controls around them. Molecule AI provides those controls built in.
Get started with MCP on Molecule AI in the [MCP Server Setup Guide](/docs/guides/mcp-server-setup).
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is an MCP server?",
"acceptedAnswer": {
"@type": "Answer",
"text": "An MCP server is a process that implements the Model Context Protocol (MCP). It runs separately from your AI agent and exposes tools, resources, and prompts that any MCP-compatible AI agent can discover and call — without custom code on the agent side."
}
},
{
"@type": "Question",
"name": "How do I add an MCP server to Molecule AI?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Add the server configuration to your Molecule AI workspace config under the mcpServers key. Most servers can be started with a single command (npx, uvx, or Docker) and then referenced in your workspace configuration. Molecule AI's workspace isolation handles the container lifecycle automatically."
}
},
{
"@type": "Question",
"name": "Which MCP servers are officially supported?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Molecule AI supports the full MCP ecosystem. Reference servers (filesystem, git, memory, brave-search) are maintained by the modelcontextprotocol GitHub organization. Official integrations from Slack, GitHub, AWS, and Google are also available. Any MCP server in the ecosystem is compatible with Molecule AI."
}
},
{
"@type": "Question",
"name": "How do I evaluate an MCP server before adding it to my agent?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Start narrow — add MCP servers only when your agent's tasks require them. Evaluate each server's security posture, credential requirements, and failure modes before adding it. Molecule AI's workspace-level server configuration makes it easy to add servers incrementally and revoke access at the workspace boundary."
}
},
{
"@type": "Question",
"name": "Can I build a custom MCP server?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. MCP server frameworks in Python (official), TypeScript, and Go let you build custom integrations without implementing the protocol from scratch. Package your server as a Docker image and reference it by image URL in your Molecule AI workspace server config for one-command deployment."
}
}
]
}
</script>
---
*To stay current with the MCP ecosystem, watch the [modelcontextprotocol GitHub organization](https://github.com/modelcontextprotocol) for new server releases and protocol updates. This MCP server list is updated as the ecosystem evolves.*

View File

@ -68,16 +68,13 @@ Until role scoping ships: name your keys well, monitor their usage, and treat th
## Monitoring what your agents call
Once an agent is running on an org-scoped key, the audit log is your instrument panel:
Once an agent is running on an org-scoped key, you monitor it the same way you'd monitor any long-lived service credential:
```bash
curl https://acme.moleculesai.app/org/tokens/ci-agent-prod_abc123/logs \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
**In Canvas:** Settings → Org API Keys → [key name] → Activity Log shows recent calls for that key.
Returns a paginated log of every call the key has made — timestamp, endpoint, response code, duration. Rotate this view into your observability stack and you have agent-level call attribution without any agent-side instrumentation.
**Per-token activity logs via API** (planned): a structured API endpoint for querying an org-scoped key's call history — timestamp, endpoint, response code, duration — is on the roadmap. Until it ships, the Canvas Activity Log is the primary monitoring interface.
If the call pattern changes — a monitoring agent suddenly starts calling `/workspaces POST` — that's a signal. Revoke the key, investigate, re-issue with tighter scope if needed.
If a monitoring agent's call pattern changes — it suddenly starts calling `/workspaces POST` instead of read-only endpoints — that's a signal. Revoke the key, investigate, and re-issue with tighter scope if needed.
## The security properties that survive agent compromise
@ -106,4 +103,4 @@ curl -X POST https://acme.moleculesai.app/org/tokens \
Store the returned plaintext token in your secret manager. Hand it to the agent. Monitor the key's usage in Settings → Org API Keys → [key name] → Activity Log.
*Org-scoped API keys shipped in PRs #1105, #1107, #1109, and #1110. Role scoping and per-workspace bindings are on the roadmap.*
*Org-scoped API keys shipped in PRs #1105, #1107, #1109, and #1110. Role scoping, per-workspace bindings, and per-token activity logs via API are on the roadmap.*

View File

@ -0,0 +1,260 @@
---
title: "How Molecule AI's A2A Protocol Works: Peer-to-Peer Agent Communication"
description: "A technical deep-dive into Molecule AI's A2A v1.0 implementation — JSON-RPC message format, SSE streaming, Redis key resolution, and the peer-to-peer routing model that keeps the platform out of your agent-to-agent traffic."
date: 2026-04-23
slug: a2a-protocol-deep-dive
og_title: "How Molecule AI's A2A Protocol Works"
og_description: "Peer-to-peer agent communication — JSON-RPC, SSE streams, Redis key resolution, and the routing model that keeps the platform out of your traffic."
canonical: https://docs.molecule.ai/blog/a2a-protocol-deep-dive
---
*Meta description (160 chars): Protocol-native A2A in production — JSON-RPC, SSE, peer-to-peer routing, and why the platform never touches your agent messages.*
---
Most A2A explainers stop at the message format. This one goes further: you'll see exactly what a message looks like on the wire, how agent discovery works without a central registry, and why Molecule AI's peer-to-peer routing model means the platform is architecturally incapable of reading your agent-to-agent traffic.
If you're evaluating agent platforms, this is the layer that determines whether A2A is a feature or a constraint.
## The Protocol Layer
A2A v1.0 is built on JSON-RPC 2.0. Every message between agents is a valid JSON-RPC request or response, which means it works with any HTTP client and any JSON library in any language.
The `message/send` call — the core primitive — takes a target agent ID and a task payload:
```json
{
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"message_id": "msg_01hx3k...",
"task_id": "task_01hx3k...",
"role": "user",
"content": {
"kind": "text",
"text": "Run the security audit on the payment service workspace"
}
},
"target_agent_id": "ws_01hx3k...",
"metadata": {}
},
"id": 1
}
```
The `task_id` is client-generated and idempotent — if you send the same `task_id` twice, Molecule AI treats the second call as a duplicate and returns the cached response rather than re-executing. This is how you get at-least-once delivery without building your own deduplication layer.
## Peer-to-Peer Routing
Here's the part that matters architecturally.
When an agent sends a message, it POSTs to the platform's A2A proxy at `POST /workspaces/:id/a2a`. The proxy does three things:
1. **Validates** the caller's bearer token and `X-Workspace-ID` header
2. **Looks up** the target workspace's current URL from the registry
3. **Forwards** the message directly to the target — the platform writes the HTTP request, not the message content
After forwarding, the platform's job is done. The response comes back directly from the target agent to the caller. The platform is never in the message path for the response.
```
Agent A Platform Agent B
| | |
|-- POST /workspaces/:id/a2a ----->| |
| { target: ws_B, content: ... } | |
| |-- POST http://agent-b:3001 -->|
| | (original message, unchanged)|
| | |
| |<-- HTTP response --------------|
|<-- original A2A response --------| |
(platform proxy wrote the request, but the response is Agent B's)
```
The platform's role is a post office, not a router. It resolves addresses and drops envelopes. It does not read the letters.
### JSON-RPC Wrapping
The platform wraps your message in a JSON-RPC envelope before forwarding:
```json
{
"method": "message/send",
"params": {
"message": {
"message_id": "msg_01hx3k...",
"content": { "kind": "text", "text": "Run the security audit" }
}
},
"id": 1
}
```
The `params.metadata` field carries non-JSON-RPC extensions — `run_id` for grouping parallel calls, `source_workspace_id` for audit attribution, and any custom key-value pairs your integration needs to propagate. The platform preserves `metadata` end-to-end.
## Agent Discovery: Register Once, Message Anyone
Agents don't need a pre-configured address book. They register with the platform and the platform resolves addresses on demand.
Registering looks like this:
```python
import requests, os, time, threading
PLATFORM = os.environ["PLATFORM_URL"]
WORKSPACE_ID = os.environ["WORKSPACE_ID"]
AUTH_TOKEN = os.environ["AUTH_TOKEN"]
resp = requests.post(
f"{PLATFORM}/registry/register",
json={
"id": WORKSPACE_ID,
"url": os.environ["AGENT_URL"],
"agent_card": {
"name": "Security Auditor",
"skills": ["security", "audit", "python"]
}
},
headers={"Authorization": f"Bearer {AUTH_TOKEN}"}
)
token = resp.json()["token"] # per-workspace bearer, not a shared key
def heartbeat():
while True:
requests.post(
f"{PLATFORM}/registry/heartbeat",
json={"workspace_id": WORKSPACE_ID, "error_rate": 0.0,
"active_tasks": 0, "uptime_seconds": 0},
headers={"Authorization": f"Bearer {token}"}
)
time.sleep(30)
threading.Thread(target=heartbeat, daemon=True).start()
```
The response includes a per-workspace bearer token scoped to exactly this workspace — it cannot be used to access any other workspace, even if the token is intercepted.
When Agent A wants to message Agent B, it calls `GET /registry/discover/:id` with Agent B's workspace ID. The platform returns Agent B's current URL and a snapshot of its agent card. Agent A then POSTs directly to that URL. Discovery is a single API call, not a permanent channel.
```json
// GET /registry/discover/ws_01hx3k...
{
"agent_card": {
"name": "Security Auditor",
"skills": ["security", "audit", "python"]
},
"url": "http://audit-workspace:3001",
"last_seen": "2026-04-23T14:32:01Z"
}
```
The `last_seen` timestamp tells you whether the target is online. Agents that haven't sent a heartbeat in 90 seconds are marked offline — messages to them return a `workspace_offline` error rather than hanging.
## Authentication at Discovery Time
Every discovery call and every A2A call requires a valid bearer token. The platform enforces this at the transport layer — not as a policy, not as a middleware configuration, but as a hard requirement on every authenticated route.
The `CanCommunicate(callerID, targetID)` check runs before any message is forwarded:
```python
def CanCommunicate(caller_id: str, target_id: str) -> bool:
# Same workspace — always allowed
if caller_id == target_id:
return True
# Parent-child relationship — allowed
if is_parent_of(caller_id, target_id):
return True
if is_parent_of(target_id, caller_id):
return True
# Siblings (same parent) — allowed
if share_parent(caller_id, target_id):
return True
# Root-level siblings (both parent_id IS NULL) — allowed
if both_root_level(caller_id, target_id):
return True
# Everything else — denied
return False
```
The same-workspace check means any two agents in the same workspace can communicate without going through a hierarchy approval — they are, by definition, in the same trust boundary. Cross-workspace communication requires either a parent-child relationship or sibling-sharing.
This is enforced in `workspace-server/internal/registry/access.go`. The Go implementation is the authoritative reference — the Python pseudocode above reflects the logic, not the production code.
## SSE Streaming for Long-Running Tasks
Agentic tasks are not always short. When an agent starts a task that takes minutes, you need to track progress without polling.
Molecule AI's A2A implementation supports Server-Sent Events for task progress. The caller receives a stream of `progress` events followed by a final `task_complete` or `error`:
```
event: progress
data: {"run_id":"run_01hx3k...","progress":0.25,"message":"Scanning 140 services..."}
event: progress
data: {"run_id":"run_01hx3k...","progress":0.60,"message":"Running CVE check on 23 packages..."}
event: task_complete
data: {"run_id":"run_01hx3k...","result":{"kind":"text","text":"3 critical CVEs found. Patch recommendation ready."}}
```
The `run_id` groups parallel calls — when an agent fires multiple tool calls simultaneously, each call gets a separate `run_id` so you can track them independently while seeing the full execution tree.
## Redis Key Resolution: How the Platform Tracks Agents
Behind the discovery API, Molecule AI uses Redis for agent registry state:
```
workspace:{id}:url -> "http://audit-workspace:3001"
workspace:{id}:card -> {"name":"Security Auditor","skills":[...]}
workspace:{id}:heartbeat -> "2026-04-23T14:32:01Z" (TTL: 90s)
workspace:{id}:org -> "org_01hx3k..."
```
The 90-second TTL on the heartbeat key is what drives the offline detection. When the heartbeat loop stops — because the agent crashed, was paused, or lost network — the key expires and the platform stops routing messages to that workspace.
This is the same Redis pub/sub used for the WebSocket event bus. When an agent's heartbeat key expires, the platform broadcasts a `WORKSPACE_OFFLINE` event over Redis, the WebSocket hub picks it up, and the canvas updates the agent's status in real time. The agent then gets auto-restarted by the provisioner.
The full cycle: heartbeat TTL expires → `WORKSPACE_OFFLINE` broadcast → canvas updates → provisioner restarts container → agent re-registers → discovery works again. No manual intervention required.
## Why This Matters for Your Architecture
The peer-to-peer model has concrete implications for teams building on Molecule AI.
**Latency:** Messages go agent-to-agent after the initial discovery hop. The platform adds one round-trip overhead (the discovery call), then all subsequent traffic is direct. For agents behind the same Redis pub/sub bus, latency is sub-millisecond.
**Privacy:** The platform proxy never reads message content. It resolves addresses, enforces auth, and forwards bytes. If your compliance team requires that messages between agents are never visible to the platform operator, the architecture satisfies that requirement structurally — not by policy.
**Scalability:** The registry is a Redis key-value store, not a database. Registration, heartbeat, and discovery are all O(1) operations. There's no central message queue to saturate, no fan-out bottleneck, no single point of contention.
**Auditability:** Every call to the A2A proxy is logged to `structure_events` with the caller's bearer token prefix and `X-Workspace-ID`. The audit trail captures who messaged whom and when — it doesn't capture the message content itself, which stays between the two agents.
## LangGraph Is Shipping A2A — Here's the Difference
LangGraph's A2A PRs (#6645, #7113) are real and close to landing (Q2-Q3 2026 GA). The protocol layer is solid — message format, transport, capability negotiation. What they're building is what Molecule AI shipped in Phase 30.
The gap is governance:
| | Molecule AI | LangGraph (projected) |
|---|---|---|
| JSON-RPC message format | ✅ Production | ✅ In review |
| Agent discovery | ✅ On-demand | ✅ In review |
| Peer-to-peer routing | ✅ Platform never in message path | ⚠️ Proxy in path |
| Per-workspace auth tokens | ✅ Phase 30 | ❌ Not in current PRs |
| `X-Workspace-ID` enforcement | ✅ Protocol-level | ❌ Not in current PRs |
| `CanCommunicate` access model | ✅ Production | ❌ Not in current PRs |
| Cross-network federation | ✅ Phase 30 | ❌ Not in current PRs |
| Org-scoped delegation attribution | ✅ Phase 33 | ❌ Not in current PRs |
Molecule AI's A2A implementation is production-ready today. The governance features that make A2A safe for enterprise — workspace scoping, immutable audit trails, cross-network federation — are already live. If you need those capabilities, you don't have to wait for LangGraph's roadmap.
## Get Started
To register an external agent, follow the [External Agent Registration Guide](https://docs.molecule.ai/docs/guides/external-agent-registration). The A2A protocol spec with full JSON-RPC reference is at [docs/api-protocol/a2a-protocol.md](https://docs.molecule.ai/docs/api-protocol/a2a-protocol).
For the MCP server that wraps the platform API: `npx @molecule-ai/mcp-server`.
If you're building a multi-agent workflow and want to understand how the pieces fit together, the [workspace runtime docs](https://docs.molecule.ai/docs/agent-runtime/workspace-runtime) cover the adapter model and how external agents integrate.

View File

@ -8,6 +8,7 @@ og_description: "Platform Instructions: global and workspace-scoped rules prepen
tags: [governance, platform-instructions, enterprise, security, it-governance, system-prompt, policy, a2a]
keywords: [AI fleet governance, enterprise AI policy, system prompt governance, AI agent compliance, platform instructions, workspace policy enforcement, enterprise AI security, AI agent ACL]
canonical: https://docs.molecule.ai/blog/govern-ai-fleet-system-prompt-level
og_image: ""
---
<script type="application/ld+json">
@ -99,6 +100,7 @@ Platform Instructions are available on **Enterprise plans**. To get started:
- Verify resolved instructions via `GET /workspaces/{id}/instructions/resolve`
For a complete governance picture, combine Platform Instructions with [Tool Trace](/blog/ai-agent-observability-without-overhead/) — see exactly which tools were called and what inputs were passed, alongside the policy that governed them.
- See both features in one post: [Tool Trace + Platform Instructions Overview](/blog/tool-trace-platform-instructions-overview/)
---

View File

@ -8,6 +8,7 @@ og_description: "See every tool your agent called — inputs, outputs, timing
tags: [observability, tool-trace, debugging, devops, platform-engineering, a2a, claude]
keywords: [AI agent observability, tool trace debugging, Claude agent debugging, agent audit trail, parallel tool call trace, run_id pairing, AI agent monitoring, DevOps agent observability]
canonical: https://docs.molecule.ai/blog/ai-agent-observability-without-overhead
og_image: ""
---
<script type="application/ld+json">
@ -106,6 +107,7 @@ Combined with the [org-scoped API key audit trail](/docs/blog/2026-04-21-org-sco
- Query `activity_logs.tool_trace` JSONB for historical traces
- Combine with org API key attribution for complete fleet observability
- Read the [A2A protocol documentation](/docs/api-protocol/a2a-protocol.md)
- Govern agent behavior with [Platform Instructions — system-prompt level policy](/blog/govern-ai-fleet-system-prompt-level/)
---

Some files were not shown because too many files have changed in this diff Show More