Compare commits

..

No commits in common. "main" and "feat/plugin-version-subscription" have entirely different histories.

165 changed files with 849 additions and 8355 deletions

View File

@ -1,118 +0,0 @@
#!/usr/bin/env bash
# audit-force-merge — detect a §SOP-6 force-merge after PR close, emit
# `incident.force_merge` to stdout as structured JSON.
#
# Vector's docker_logs source picks up runner stdout; the JSON gets
# shipped to Loki on molecule-canonical-obs, indexable by event_type.
# Query example:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
#
# A force-merge is detected when a PR closed-with-merged=true had at
# least one of the repo's required-status-check contexts in a state
# other than "success" at the merge commit's SHA. That's exactly what
# the Gitea force_merge:true API call lets through, so it's a faithful
# detector of the override path.
#
# Triggers on `pull_request_target: closed` (loaded from base branch
# per §SOP-6 security model). No-op when merged=false.
#
# Required env (set by the workflow):
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS
#
# REQUIRED_CHECKS is a newline-separated list of status-check context
# names that branch protection requires. Declared in the workflow YAML
# rather than fetched from /branch_protections (which needs admin
# scope — sop-tier-bot has read-only). Trade dynamism for simplicity:
# when the required-check set changes, update both branch protection
# AND this env. Keeping them in sync is less complexity than granting
# the audit bot admin perms on every repo.
set -euo pipefail
: "${GITEA_TOKEN:?required}"
: "${GITEA_HOST:?required}"
: "${REPO:?required}"
: "${PR_NUMBER:?required}"
: "${REQUIRED_CHECKS:?required (newline-separated context names)}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
# 1. Fetch the PR. If not merged, no-op.
PR=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
MERGED=$(echo "$PR" | jq -r '.merged // false')
if [ "$MERGED" != "true" ]; then
echo "::notice::PR #${PR_NUMBER} closed without merge — no audit emission."
exit 0
fi
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
TITLE=$(echo "$PR" | jq -r '.title // ""')
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
if [ -z "$MERGE_SHA" ]; then
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
exit 0
fi
# 2. Required status checks declared in the workflow env.
REQUIRED="$REQUIRED_CHECKS"
if [ -z "${REQUIRED//[[:space:]]/}" ]; then
echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable."
exit 0
fi
# 3. Status-check state at the PR HEAD (where checks ran). The merge
# commit doesn't get its own checks; we evaluate the PR's last
# commit, which is what branch protection compared against.
STATUS=$(curl -sS -H "$AUTH" \
"${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status")
declare -A CHECK_STATE
while IFS=$'\t' read -r ctx state; do
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
# 4. For each required check, was it green at merge? YAML block scalars
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
FAILED_CHECKS=()
while IFS= read -r req; do
trimmed="${req#"${req%%[![:space:]]*}"}" # ltrim
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rtrim
[ -z "$trimmed" ] && continue
state="${CHECK_STATE[$trimmed]:-missing}"
if [ "$state" != "success" ]; then
FAILED_CHECKS+=("${trimmed}=${state}")
fi
done <<< "$REQUIRED"
if [ "${#FAILED_CHECKS[@]}" -eq 0 ]; then
echo "::notice::PR #${PR_NUMBER} merged with all required checks green — not a force-merge."
exit 0
fi
# 5. Emit structured audit event.
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
# Print as a single-line JSON so Vector's parse_json transform can pick
# it up cleanly from docker_logs.
jq -nc \
--arg event_type "incident.force_merge" \
--arg ts "$NOW" \
--arg repo "$REPO" \
--argjson pr "$PR_NUMBER" \
--arg title "$TITLE" \
--arg base "$BASE_BRANCH" \
--arg merged_by "$MERGED_BY" \
--arg merge_sha "$MERGE_SHA" \
--argjson failed_checks "$FAILED_JSON" \
'{event_type: $event_type, ts: $ts, repo: $repo, pr: $pr, title: $title,
base_branch: $base, merged_by: $merged_by, merge_sha: $merge_sha,
failed_checks: $failed_checks}'
echo "::warning::FORCE-MERGE detected on PR #${PR_NUMBER} by ${MERGED_BY}: ${#FAILED_CHECKS[@]} required check(s) not green at merge time."

View File

@ -1,149 +0,0 @@
#!/usr/bin/env bash
# sop-tier-check — verify a Gitea PR satisfies the §SOP-6 approval gate.
#
# Reads the PR's tier label, walks approving reviewers, and checks each
# approver's Gitea team membership against the tier's eligible-team set.
# Marks pass only when at least one non-author approver is in an eligible
# team.
#
# Invoked from `.gitea/workflows/sop-tier-check.yml`. The workflow sets
# the env vars below; this script does no IO outside of stdout/stderr +
# the Gitea API.
#
# Required env:
# GITEA_TOKEN — bot PAT with read:organization,read:user,
# read:issue,read:repository scopes
# GITEA_HOST — e.g. git.moleculesai.app
# REPO — owner/name (from github.repository)
# PR_NUMBER — int (from github.event.pull_request.number)
# PR_AUTHOR — login (from github.event.pull_request.user.login)
#
# Optional:
# SOP_DEBUG=1 — print per-API-call diagnostic lines (HTTP codes,
# raw response bodies). Default: off.
#
# Stale-status caveat: Gitea Actions does not always re-fire workflows
# on `labeled` / `pull_request_review:submitted` events. If the
# sop-tier-check status is stale (e.g. red after labels/approvals were
# added), push an empty commit to the PR branch to force a synchronize
# event, OR re-request reviews. Tracked: internal#46.
set -euo pipefail
debug() {
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] $*" >&2
fi
}
# Validate env
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required (owner/name)}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${PR_AUTHOR:?PR_AUTHOR required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
# Sanity: token resolves to a user
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
# 1. Read tier label
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
TIER=""
for L in $LABELS; do
case "$L" in
tier:low|tier:medium|tier:high)
if [ -n "$TIER" ]; then
echo "::error::Multiple tier labels: $TIER + $L. Apply exactly one."
exit 1
fi
TIER="$L"
;;
esac
done
if [ -z "$TIER" ]; then
echo "::error::PR has no tier:low|tier:medium|tier:high label. Apply one before merge."
exit 1
fi
debug "tier=$TIER"
# 2. Tier → eligible teams
case "$TIER" in
tier:low) ELIGIBLE="engineers managers ceo" ;;
tier:medium) ELIGIBLE="managers ceo" ;;
tier:high) ELIGIBLE="ceo" ;;
esac
debug "eligible_teams=$ELIGIBLE"
# Resolve team-name → team-id once. /orgs/{org}/teams/{slug}/... endpoints
# don't exist on Gitea 1.22; we have to use /teams/{id}.
ORG_TEAMS_FILE=$(mktemp)
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/teams")
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] teams-list body (first 300 chars):" >&2
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope. Add a SOP_TIER_CHECK_TOKEN secret with read:organization scope at the org level."
exit 1
fi
declare -A TEAM_ID
for T in $ELIGIBLE; do
ID=$(jq -r --arg t "$T" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
VISIBLE=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
echo "::error::Team \"$T\" not found in org $OWNER. Teams visible: $VISIBLE"
exit 1
fi
TEAM_ID[$T]="$ID"
debug "team-id: $T$ID"
done
# 3. Read approving reviewers
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
if [ -z "$APPROVERS" ]; then
echo "::error::No approving reviews. Tier $TIER requires approval from {$ELIGIBLE} (non-author)."
exit 1
fi
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
# 4. For each approver: check non-author + team membership (by id)
OK=""
for U in $APPROVERS; do
if [ "$U" = "$PR_AUTHOR" ]; then
debug "skip self-review by $U"
continue
fi
for T in $ELIGIBLE; do
ID="${TEAM_ID[$T]}"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/teams/${ID}/members/${U}")
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
echo "::notice::approver $U is in team $T (eligible for $TIER)"
OK="yes"
break
fi
done
[ -n "$OK" ] && break
done
if [ -z "$OK" ]; then
echo "::error::Tier $TIER requires approval from a non-author member of {$ELIGIBLE}. Got approvers: $APPROVERS — none of them satisfied team membership. Set SOP_DEBUG=1 to see per-probe HTTP codes."
exit 1
fi
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"

View File

@ -1,58 +0,0 @@
# audit-force-merge — emit `incident.force_merge` to runner stdout when
# a PR is merged with required-status-checks not green. Vector picks
# the JSON line off docker_logs and ships to Loki on
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
#
# Closes the §SOP-6 audit gap (the doc says force-merges write to
# `structure_events`, but that table lives in the platform DB, not
# Gitea-side; Loki is the practical equivalent for Gitea Actions
# events). When the credential / observability stack converges later,
# this can sync into structure_events from Loki via a backfill job —
# the structured JSON shape is forward-compatible.
#
# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script-
# extract pattern as sop-tier-check.
name: audit-force-merge
# pull_request_target loads from the base branch — same security model
# as sop-tier-check. Without this, an attacker could rewrite the
# workflow on a PR and skip the audit emission for their own
# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full
# rationale.
on:
pull_request_target:
types: [closed]
jobs:
audit:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
# Skip when PR is closed without merge — saves a runner.
if: github.event.pull_request.merged == true
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Detect force-merge + emit audit event
env:
# Same org-level secret the sop-tier-check workflow uses.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# Required-status-check contexts to evaluate at merge time.
# Newline-separated. Mirror this against branch protection
# (settings → branches → protected branch → required checks).
# Declared here rather than fetched from /branch_protections
# because that endpoint requires admin write — sop-tier-bot is
# read-only by design (least-privilege).
REQUIRED_CHECKS: |
sop-tier-check / tier-check (pull_request)
Secret scan / Scan diff for credential-shaped strings (pull_request)
run: bash .gitea/scripts/audit-force-merge.sh

View File

@ -1,303 +0,0 @@
name: publish-runtime
# Gitea Actions port of .github/workflows/publish-runtime.yml.
#
# Ported 2026-05-10 (issue #206). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment: pypi-publish` — Gitea Actions does not support
# named environments or OIDC trusted publishers
# - Replaced `pypa/gh-action-pypi-publish@release/v1` (OIDC) with
# `twine upload` using PYPI_TOKEN secret — same mechanism as a local
# `python -m twine upload` with a PyPI token
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
# - Dropped `merge_group` trigger (Gitea has no merge queue)
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
#
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
# The token should be a PyPI API token scoped to molecule-ai-workspace-runtime.
#
# The DISPATCH_TOKEN cascade (git push to template repos) is unchanged —
# it uses the Gitea API directly and was already Gitea-compatible.
on:
push:
tags:
- "runtime-v*"
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
required: true
type: string
permissions:
contents: read
# Serialize publishes so two concurrent tag pushes don't both compute
# "latest+1" and race on PyPI upload. The second one waits.
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
- name: Derive version (tag, manual input, or PyPI auto-bump)
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
elif echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
# Tag is `runtime-vX.Y.Z` — strip the prefix.
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
else
# Fallback: derive from PyPI latest + patch bump.
# (The staging-push auto-bump trigger is dropped on Gitea —
# no staging branch exists. This fallback path is kept for
# robustness if a future automation uses workflow_dispatch without
# an explicit version input.)
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "Auto-bumped from PyPI latest $LATEST -> $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+|rc[0-9]+|a[0-9]+|b[0-9]+|\.post[0-9]+)?$'; then
echo "::error::version $VERSION does not match PEP 440"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing molecule-ai-workspace-runtime $VERSION"
- name: Install build tooling
run: pip install build twine
- name: Build package from workspace/
run: |
python scripts/build_runtime_package.py \
--version "${{ steps.version.outputs.version }}" \
--out "${{ runner.temp }}/runtime-build"
- name: Build wheel + sdist
working-directory: ${{ runner.temp }}/runtime-build
run: python -m build
- name: Capture wheel SHA256 for cascade content-verification
id: wheel_hash
working-directory: ${{ runner.temp }}/runtime-build
run: |
set -eu
WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
if [ -z "$WHEEL" ]; then
echo "::error::No .whl in dist/ — \`python -m build\` must have failed silently"
exit 1
fi
HASH=$(sha256sum "$WHEEL" | awk '{print $1}')
echo "wheel_sha256=${HASH}" >> "$GITHUB_OUTPUT"
echo "Local wheel SHA256 (pre-upload): ${HASH}"
echo "Wheel filename: $(basename "$WHEEL")"
- name: Verify package contents (sanity)
working-directory: ${{ runner.temp }}/runtime-build
run: |
python -m twine check dist/*
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
- name: Publish to PyPI
env:
# PYPI_TOKEN: repository secret scoped to molecule-ai-workspace-runtime.
# Set via: Settings → Actions → Variables and Secrets → New Secret.
# Format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
if [ -z "$PYPI_TOKEN" ]; then
echo "::error::PYPI_TOKEN secret is not set — set it at Settings → Actions → Variables and Secrets → New Secret."
echo "::error::Required format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
exit 1
fi
python -m twine upload \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
dist/*
cascade:
needs: publish
runs-on: ubuntu-latest
steps:
- name: Wait for PyPI to propagate the new version
env:
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
EXPECTED_SHA256: ${{ needs.publish.outputs.wheel_sha256 }}
run: |
set -eu
if [ -z "$EXPECTED_SHA256" ]; then
echo "::error::publish job did not expose wheel_sha256 — cannot verify wheel content. Refusing to fan out cascade."
exit 1
fi
python -m venv /tmp/propagation-probe
PROBE=/tmp/propagation-probe/bin
$PROBE/pip install --upgrade --quiet pip
for i in $(seq 1 30); do
if $PROBE/pip install \
--quiet \
--no-cache-dir \
--force-reinstall \
--no-deps \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
>/dev/null 2>&1; then
INSTALLED=$($PROBE/pip show molecule-ai-workspace-runtime 2>/dev/null \
| awk -F': ' '/^Version:/{print $2}')
if [ "$INSTALLED" = "$RUNTIME_VERSION" ]; then
echo "✓ PyPI resolved $RUNTIME_VERSION (install check)"
break
fi
fi
if [ $i -eq 30 ]; then
echo "::error::pip install --no-cache-dir molecule-ai-workspace-runtime==${RUNTIME_VERSION} never resolved within ~5 min."
echo "::error::Refusing to fan out cascade against a potentially stale PyPI index."
exit 1
fi
echo " [$i/30] waiting for PyPI to propagate ${RUNTIME_VERSION}..."
sleep 4
done
# Stage (b): download wheel + SHA256 compare against what we built.
# Catches Fastly stale-content serving old bytes under a new version URL.
HASH=$(python -m pip download \
--no-deps \
--no-cache-dir \
--dest /tmp/wheel-probe \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
2>/dev/null \
&& sha256sum /tmp/wheel-probe/*.whl | awk '{print $1}')
if [ "$HASH" != "$EXPECTED_SHA256" ]; then
echo "::error::PyPI propagated $RUNTIME_VERSION but wheel content SHA256 mismatch."
echo "::error::Expected: $EXPECTED_SHA256"
echo "::error::Got: $HASH"
echo "::error::Fastly may be serving stale content. Refusing to fan out cascade."
exit 1
fi
echo "✓ PyPI CDN verified (SHA256 match)"
- name: Fan out via push to .runtime-version
env:
# Gitea PAT with write:repository scope on the 8 cascade-active
# template repos. Used for git push to each template repo's main
# branch, which trips their `on: push: branches: [main]` trigger
# on publish-image.yml.
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
run: |
set +e # don't abort on a single repo failure — collect them all
if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade."
echo "::warning::set it at Settings → Actions → Variables and Secrets → New Secret."
exit 0
fi
echo "::error::DISPATCH_TOKEN secret missing — cascade cannot fan out."
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version."
exit 1
fi
VERSION="$RUNTIME_VERSION"
if [ -z "$VERSION" ]; then
echo "::error::publish job did not expose a version output"
exit 1
fi
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
SKIPPED=""
git config --global user.name "publish-runtime cascade"
git config --global user.email "publish-runtime@moleculesai.app"
WORKDIR="$(mktemp -d)"
for tpl in $TEMPLATES; do
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
CLONE="$WORKDIR/$tpl"
HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \
-H "Authorization: token $DISPATCH_TOKEN" \
"$GITEA_URL/api/v1/repos/$REPO/contents/.github/workflows/publish-image.yml")
if [ "$HTTP" = "404" ]; then
echo "↷ $tpl has no publish-image.yml — soft-skip"
SKIPPED="$SKIPPED $tpl"
continue
fi
attempt=0
success=false
while [ $attempt -lt 3 ]; do
attempt=$((attempt + 1))
rm -rf "$CLONE"
if ! git clone --depth=1 \
"https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/$REPO.git" \
"$CLONE" >/tmp/clone.log 2>&1; then
echo "::warning::clone $tpl attempt $attempt failed: $(tail -n3 /tmp/clone.log)"
sleep 2
continue
fi
cd "$CLONE"
echo "$VERSION" > .runtime-version
if git diff --quiet -- .runtime-version; then
echo "✓ $tpl already at $VERSION — no commit needed"
success=true
cd - >/dev/null
break
fi
git add .runtime-version
git commit -m "chore: pin runtime to $VERSION (publish-runtime cascade)" \
-m "Co-Authored-By: publish-runtime cascade <publish-runtime@moleculesai.app>" \
>/dev/null
if git push origin HEAD:main >/tmp/push.log 2>&1; then
echo "✓ $tpl pushed $VERSION on attempt $attempt"
success=true
cd - >/dev/null
break
fi
echo "::warning::push $tpl attempt $attempt failed, pull-rebasing"
git pull --rebase origin main >/tmp/rebase.log 2>&1 || true
cd - >/dev/null
done
if [ "$success" != "true" ]; then
FAILED="$FAILED $tpl"
fi
done
rm -rf "$WORKDIR"
if [ -n "$FAILED" ]; then
echo "::error::Cascade incomplete after 3 retries each. Failed:$FAILED"
exit 1
fi
if [ -n "$SKIPPED" ]; then
echo "Cascade complete: pinned $VERSION. Soft-skipped (no publish-image.yml):$SKIPPED"
else
echo "Cascade complete: $VERSION pinned across all manifest workspace_templates."
fi

View File

@ -1,191 +0,0 @@
name: Secret scan
# Hard CI gate. Refuses any PR / push whose diff additions contain a
# recognisable credential. Defense-in-depth for the #2090-class incident
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
# installation token into tenant-proxy/package.json via `npm init`
# slurping the URL from a token-embedded origin remote. We can't fix
# upstream's clone hygiene, so we gate here.
#
# Same regex set as the runtime's bundled pre-commit hook
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
# Keep the two sides aligned when adding patterns.
#
# Ported from .github/workflows/secret-scan.yml so the gate actually
# fires on Gitea Actions. Differences from the GitHub version:
# - drops `merge_group` event (Gitea has no merge queue)
# - drops `workflow_call` (no cross-repo reusable invocation on Gitea)
# - SELF path updated to .gitea/workflows/secret-scan.yml
# The job name + step name are identical to the GitHub workflow so the
# status-check context (`Secret scan / Scan diff for credential-shaped
# strings (pull_request)`) matches branch protection on molecule-core/main.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
jobs:
scan:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
# For pull_request events the diff base may be many commits behind
# HEAD and absent from the shallow clone. Fetch it explicitly.
- 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 credential-shaped strings appear in diff additions
env:
# Plumb event-specific SHAs through env so the script doesn't
# need conditional `${{ ... }}` interpolation per event type.
# github.event.before/after only exist on push events;
# pull_request has pull_request.base.sha / pull_request.head.sha.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.event.after }}
run: |
# Pattern set covers GitHub family (the actual #2090 vector),
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
# false-positive rates against agent-generated content. Mirror of
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
# — keep aligned.
SECRET_PATTERNS=(
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
'AKIA[0-9A-Z]{16}' # AWS access key ID
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
)
# Determine the diff base. Each event type stores its SHAs in
# a different place — see the env block above.
case "${{ github.event_name }}" in
pull_request)
BASE="$PR_BASE_SHA"
HEAD="$PR_HEAD_SHA"
;;
*)
BASE="$PUSH_BEFORE"
HEAD="$PUSH_AFTER"
;;
esac
# On push events with shallow clones, BASE may be present in
# the event payload but absent from the local object DB
# (fetch-depth=2 doesn't always reach the previous commit
# across true merges). Try fetching it on demand. If the
# fetch fails — e.g. the SHA was force-overwritten — we fall
# through to the empty-BASE branch below, which scans the
# entire tree as if every file were new. Correct, just slow.
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
fi
# Files added or modified in this change.
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
# New branch / no previous SHA / BASE unreachable — check the
# entire tree as added content. Slower, but correct on first
# push.
CHANGED=$(git ls-tree -r --name-only HEAD)
DIFF_RANGE=""
else
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
DIFF_RANGE="$BASE $HEAD"
fi
if [ -z "$CHANGED" ]; then
echo "No changed files to inspect."
exit 0
fi
# Self-exclude: this workflow file legitimately contains the
# pattern strings as regex literals. Without an exclude it would
# block its own merge. Both the .github/ original and this
# .gitea/ port are excluded so a sync between them stays clean.
SELF_GITHUB=".github/workflows/secret-scan.yml"
SELF_GITEA=".gitea/workflows/secret-scan.yml"
OFFENDING=""
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
# containing whitespace don't word-split silently — a path
# with a space would otherwise produce two iterations on
# tokens that aren't real filenames, breaking the
# self-exclude + diff lookup.
while IFS= read -r f; do
[ -z "$f" ] && continue
[ "$f" = "$SELF_GITHUB" ] && continue
[ "$f" = "$SELF_GITEA" ] && continue
if [ -n "$DIFF_RANGE" ]; then
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
else
# No diff range (new branch first push) — scan the full file
# contents as if every line were new.
ADDED=$(cat "$f" 2>/dev/null || true)
fi
[ -z "$ADDED" ] && continue
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$ADDED" | grep -qE "$pattern"; then
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
break
fi
done
done <<< "$CHANGED"
if [ -n "$OFFENDING" ]; then
echo "::error::Credential-shaped strings detected in diff additions:"
# `printf '%b' "$OFFENDING"` interprets backslash escapes
# (the literal `\n` we appended above becomes a newline)
# WITHOUT treating OFFENDING as a format string. Plain
# `printf "$OFFENDING"` is a format-string sink: a filename
# containing `%` would be interpreted as a conversion
# specifier, corrupting the error message (or printing
# `%(missing)` artifacts).
printf '%b' "$OFFENDING"
echo ""
echo "The actual matched values are NOT echoed here, deliberately —"
echo "round-tripping a leaked credential into CI logs widens the blast"
echo "radius (logs are searchable + retained)."
echo ""
echo "Recovery:"
echo " 1. Remove the secret from the file. Replace with an env var"
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
echo " process.env.X in code)."
echo " 2. If the credential was already pushed (this PR's commit"
echo " history reaches a public ref), treat it as compromised —"
echo " ROTATE it immediately, do not just remove it. The token"
echo " remains valid in git history forever and may be in any"
echo " log/cache that consumed this branch."
echo " 3. Force-push the cleaned commit (or stack a revert) and"
echo " re-run CI."
echo ""
echo "If the match is a false positive (test fixture, docs example,"
echo "or this workflow's own regex literals): use a clearly-fake"
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
echo "the length suffix, OR add the file path to the SELF exclude"
echo "list in this workflow with a short reason."
echo ""
echo "Mirror of the regex set lives in the runtime's bundled"
echo "pre-commit hook (molecule-ai-workspace-runtime:"
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
exit 1
fi
echo "✓ No credential-shaped strings in this change."

View File

@ -1,81 +0,0 @@
# sop-tier-check — canonical Gitea Actions workflow for §SOP-6 enforcement.
#
# Logic lives in `.gitea/scripts/sop-tier-check.sh` (extracted 2026-05-09
# from the previous inline-bash version). The script is the single source
# of truth; this workflow file just sets env + invokes it.
#
# Copy BOTH files (`.gitea/workflows/sop-tier-check.yml` +
# `.gitea/scripts/sop-tier-check.sh`) into any repo that wants the
# §SOP-6 PR gate enforced. Pair with branch protection on the protected
# branch:
# required_status_checks: ["sop-tier-check / tier-check (pull_request)"]
# required_approving_reviews: 1
# approving_review_teams: ["ceo", "managers", "engineers"]
#
# Tier → eligible-team mapping (mirror of dev-sop §SOP-6):
# tier:low → engineers, managers, ceo
# tier:medium → managers, ceo
# tier:high → ceo
#
# Force-merge: Owners-team override remains available out-of-band via
# the Gitea merge API; force-merge writes `incident.force_merge` to
# `structure_events` per §Persistent structured logging gate (Phase 3).
#
# Set `SOP_DEBUG: '1'` in the env block to enable per-API-call diagnostic
# lines — useful when diagnosing token-scope or team-id-resolution
# issues. Default off.
name: sop-tier-check
# SECURITY: triggers MUST use `pull_request_target`, not `pull_request`.
# `pull_request_target` loads the workflow definition from the BASE
# branch (i.e. `main`), not the PR's HEAD. With `pull_request`, anyone
# with write access to a feature branch could rewrite this file in
# their PR to dump SOP_TIER_CHECK_TOKEN (org-read scope) to logs and
# exfiltrate it. Verified 2026-05-09 against Gitea 1.22.6 —
# `pull_request_target` (added in Gitea 1.21 via go-gitea/gitea#25229)
# is the documented mitigation.
#
# This workflow does NOT call `actions/checkout` of PR HEAD code, so no
# untrusted code is ever executed in the runner — we only HTTP-call the
# Gitea API. If a future change adds a checkout step, it MUST pin to
# `${{ github.event.pull_request.base.sha }}` (NOT `head.sha`) to keep
# the trust boundary.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
pull_request_review:
types: [submitted, dismissed, edited]
jobs:
tier-check:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Pin to base.sha — pull_request_target's protection only
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Verify tier label + reviewer team membership
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
# sop-tier-bot PAT (read:organization,read:user,read:issue,
# read:repository). Stored at the org level
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
# configuration is unnecessary — every repo in the org
# picks it up automatically.
# Falls back to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default
# so production logs aren't noisy.
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-check.sh

View File

@ -20,19 +20,6 @@ on:
# a few minutes under load — that's fine for a canary. # a few minutes under load — that's fine for a canary.
- cron: '*/30 * * * *' - cron: '*/30 * * * *'
workflow_dispatch: workflow_dispatch:
inputs:
keep_on_failure:
description: >-
Skip teardown when the canary fails (debugging only). The
tenant org + EC2 + CF tunnel + DNS stay alive so an operator
can SSM into the workspace EC2 and capture docker logs of the
failing claude-code container. REMEMBER to manually delete
via DELETE /cp/admin/tenants/<slug> when done so the org
doesn't accumulate cost. Only honored on workflow_dispatch;
cron runs always tear down (we don't want unattended cron
to leak resources).
type: boolean
default: false
# Serialise with the full-SaaS workflow so they don't contend for the # Serialise with the full-SaaS workflow so they don't contend for the
# same org-create quota on staging. Different group key from # same org-create quota on staging. Different group key from
@ -93,14 +80,6 @@ jobs:
# is "Token Plan only" but cheap-per-token and fast. # is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
E2E_RUN_ID: "canary-${{ github.run_id }}" E2E_RUN_ID: "canary-${{ github.run_id }}"
# Debug-only: when an operator dispatches with keep_on_failure=true,
# the canary script's E2E_KEEP_ORG=1 path skips teardown so the
# tenant org + EC2 stay alive for SSM-based log capture. Cron runs
# never set this (the input only exists on workflow_dispatch) so
# unattended cron always tears down. See molecule-core#129
# failure mode #1 — capturing the actual exception requires
# docker logs from the live container.
E2E_KEEP_ORG: ${{ github.event.inputs.keep_on_failure == 'true' && '1' || '0' }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -158,28 +137,27 @@ jobs:
id: canary id: canary
run: bash tests/e2e/test_staging_full_saas.sh run: bash tests/e2e/test_staging_full_saas.sh
# Alerting: open a sticky issue on the FIRST failure; comment on # Alerting: open an issue only after THREE consecutive failures so
# subsequent failures; auto-close on next green. Comment-on-existing # transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
# de-duplicates so a single open issue accumulates the streak — # the issue list. If an issue is already open, we still comment on
# ops sees one issue with N comments rather than N issues. # every failure so ops sees the streak. Auto-close on next green.
# #
# Why no consecutive-failures threshold (e.g., wait 3 runs before # Threshold rationale: canary fires every 30 min, so 3 failures =
# filing): the prior threshold check used # ~90 min of consecutive red — well past any single-run flake but
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does # still tight enough that a real outage gets surfaced before the
# not expose (returns 404). On Gitea Actions the threshold call # next deploy window.
# ALWAYS failed, breaking the entire alerting step and going days
# silent on real regressions (38h+ chronic red on 2026-05-07/08
# before this fix; tracked in molecule-core#129). Filing on first
# failure is also better UX — we want to know about the first red,
# not wait 90 min for it to "count." Real flakes get one issue +
# a quick close-on-green; persistent reds accumulate comments.
- name: Open issue on failure - name: Open issue on failure
if: failure() if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
# Inject the workflow path explicitly — context.workflow is
# the *name*, not the file path the actions API needs.
WORKFLOW_PATH: '.github/workflows/canary-staging.yml'
CONSECUTIVE_THRESHOLD: '3'
with: with:
script: | script: |
const title = '🔴 Canary failing: staging SaaS smoke'; const title = '🔴 Canary failing: staging SaaS smoke';
const runURL = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
// Find an existing open canary issue (stable title match). // Find an existing open canary issue (stable title match).
// If one exists, this isn't a "first failure" — comment and exit. // If one exists, this isn't a "first failure" — comment and exit.
@ -199,12 +177,32 @@ jobs:
return; return;
} }
// No open issue yet — file one on this first failure. The // No open issue yet — check the last N-1 runs' conclusions.
// comment-on-existing branch above means subsequent failures // We open the issue only if the last (THRESHOLD-1) runs ALSO
// accumulate as comments on this same issue, so we don't // failed (so this is the 3rd consecutive red).
// spam new issues per run. const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner, repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_PATH,
status: 'completed',
per_page: threshold,
// Skip the current in-progress run; it isn't 'completed' yet.
});
// listWorkflowRuns returns recent first. We need (threshold-1)
// prior failures (current run is the threshold-th).
const priorFailures = (runs.workflow_runs || [])
.slice(0, threshold - 1)
.filter(r => r.id !== context.runId)
.filter(r => r.conclusion === 'failure')
.length;
if (priorFailures < threshold - 1) {
core.info(`Below threshold: ${priorFailures + 1}/${threshold} consecutive failures — not filing yet`);
return;
}
const body = const body =
`Canary run failed at ${new Date().toISOString()}.\n\n` + `Canary run failed at ${new Date().toISOString()}, ` +
`${threshold} consecutive runs red.\n\n` +
`Run: ${runURL}\n\n` + `Run: ${runURL}\n\n` +
`This issue auto-closes on the next green canary run. ` + `This issue auto-closes on the next green canary run. ` +
`Consecutive failures add a comment here rather than a new issue.`; `Consecutive failures add a comment here rather than a new issue.`;
@ -213,7 +211,7 @@ jobs:
title, body, title, body,
labels: ['canary-staging', 'bug'], labels: ['canary-staging', 'bug'],
}); });
core.info('Opened canary failure issue (first red)'); core.info(`Opened canary failure issue (${threshold} consecutive reds)`);
- name: Auto-close canary issue on success - name: Auto-close canary issue on success
if: success() if: success()

View File

@ -14,13 +14,6 @@ name: Check merge_group trigger on required workflows
# Reasoning for staging-only: main has its own CI gating model (PR review), # 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 # but staging is what the merge queue runs on, so it's the trigger that
# matters. # matters.
#
# Gitea stub: Gitea has no merge queue feature and no `merge_group:`
# event type. The linter would find no `merge_group:` triggers to verify
# (they don't exist on Gitea), so the lint is vacuously satisfied.
# Converting to a no-op stub keeps the workflow+job name stable for any
# commit-status context consumers while eliminating the `gh api` call
# that fails against Gitea's REST surface (#75 / PR-D).
on: on:
pull_request: pull_request:
@ -32,6 +25,9 @@ on:
paths: paths:
- '.github/workflows/**.yml' - '.github/workflows/**.yml'
- '.github/workflows/**.yaml' - '.github/workflows/**.yaml'
# Self-listen on merge_group so the linter passes its own queue run.
merge_group:
types: [checks_requested]
jobs: jobs:
check: check:
@ -40,9 +36,88 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Gitea no-op (merge queue not applicable) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify merge_group trigger on required-check workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
shell: bash
run: | run: |
echo "Gitea Actions — merge queue not supported; no-op." set -euo pipefail
echo "On GitHub this workflow lints that required-check workflows declare"
echo "merge_group: triggers to prevent queue deadlock. On Gitea that" # Branch we care about — the one merge queue runs on.
echo "constraint is inapplicable — all workflows pass vacuously." 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

@ -304,9 +304,13 @@ jobs:
needs: [changes, canvas-build] needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion). # Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
# Required to post commit comments via the GitHub API.
contents: write
steps: steps:
- name: Write deploy reminder to step summary - name: Post deploy reminder as commit comment
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ github.sha }} COMMIT_SHA: ${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: | run: |
@ -333,13 +337,10 @@ jobs:
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \ printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md "$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
# Gitea has no commit-comments API (no equivalent of gh api \
# POST /repos/{owner}/{repo}/commits/{commit_sha}/comments). --method POST \
# Write to GITHUB_STEP_SUMMARY instead — both GitHub Actions and "repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \
# Gitea Actions render this as the workflow run's summary page, --field "body=@/tmp/deploy-reminder.md"
# which is where operators look for post-deploy action items.
# (#75 / PR-D)
cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY"
# Python Lint & Test — required check, always runs. See platform-build # Python Lint & Test — required check, always runs. See platform-build
# for the rationale. # for the rationale.

View File

@ -51,7 +51,7 @@ name: E2E API Smoke Test
# * Pre-pull `alpine:latest` so the platform-server's provisioner # * Pre-pull `alpine:latest` so the platform-server's provisioner
# (`internal/handlers/container_files.go`) can stand up its # (`internal/handlers/container_files.go`) can stand up its
# ephemeral token-write helper without a daemon.io round-trip. # ephemeral token-write helper without a daemon.io round-trip.
# * Create `molecule-core-net` bridge network if missing so the # * Create `molecule-monorepo-net` bridge network if missing so the
# provisioner's container.HostConfig {NetworkMode: ...} attach # provisioner's container.HostConfig {NetworkMode: ...} attach
# succeeds. # succeeds.
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/ # Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
@ -163,12 +163,12 @@ jobs:
# when the image is already present. # when the image is already present.
docker pull alpine:latest >/dev/null docker pull alpine:latest >/dev/null
# Provisioner attaches workspace containers to # Provisioner attaches workspace containers to
# molecule-core-net (workspace-server/internal/provisioner/ # molecule-monorepo-net (workspace-server/internal/provisioner/
# provisioner.go::DefaultNetwork). The bridge already exists on # provisioner.go::DefaultNetwork). The bridge already exists on
# the operator host's docker daemon — `network create` is # the operator host's docker daemon — `network create` is
# idempotent via `|| true`. # idempotent via `|| true`.
docker network create molecule-core-net >/dev/null 2>&1 || true docker network create molecule-monorepo-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured." echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
- name: Start Postgres (docker) - name: Start Postgres (docker)
if: needs.detect-changes.outputs.api == 'true' if: needs.detect-changes.outputs.api == 'true'
run: | run: |

View File

@ -34,7 +34,7 @@ name: Handlers Postgres Integration
# So we sidestep `services:` entirely. The job container still uses # So we sidestep `services:` entirely. The job container still uses
# host-net (inherited from runner config; required for cache server # host-net (inherited from runner config; required for cache server
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling # discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
# postgres on the existing `molecule-core-net` bridge with a # postgres on the existing `molecule-monorepo-net` bridge with a
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and # UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
# read its bridge IP via `docker inspect`. A host-net job container # read its bridge IP via `docker inspect`. A host-net job container
# can reach a bridge-net container directly via the bridge IP (verified # can reach a bridge-net container directly via the bridge IP (verified
@ -44,7 +44,7 @@ name: Handlers Postgres Integration
# + No host-port collision; N parallel runs share the bridge cleanly # + No host-port collision; N parallel runs share the bridge cleanly
# + `if: always()` cleanup runs even on test-step failure # + `if: always()` cleanup runs even on test-step failure
# - One more step in the workflow (+~3 lines) # - One more step in the workflow (+~3 lines)
# - Requires `molecule-core-net` to exist on the operator host # - Requires `molecule-monorepo-net` to exist on the operator host
# (it does; declared in docker-compose.yml + docker-compose.infra.yml) # (it does; declared in docker-compose.yml + docker-compose.infra.yml)
# #
# Class B Hongming-owned CICD red sweep, 2026-05-08. # Class B Hongming-owned CICD red sweep, 2026-05-08.
@ -96,7 +96,7 @@ jobs:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }} PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
# Bridge network already exists on the operator host (declared # Bridge network already exists on the operator host (declared
# in docker-compose.yml + docker-compose.infra.yml). # in docker-compose.yml + docker-compose.infra.yml).
PG_NETWORK: molecule-core-net PG_NETWORK: molecule-monorepo-net
defaults: defaults:
run: run:
working-directory: workspace-server working-directory: workspace-server

View File

@ -56,40 +56,21 @@ jobs:
run: ${{ steps.decide.outputs.run }} run: ${{ steps.decide.outputs.run }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
run:
- 'workspace-server/**'
- 'canvas/**'
- 'tests/harness/**'
- '.github/workflows/harness-replays.yml'
- id: decide - id: decide
run: | run: |
# workflow_dispatch: always run (manual trigger)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "run=true" >> "$GITHUB_OUTPUT" echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=manual-trigger" >> "$GITHUB_OUTPUT"
exit 0
fi
# Determine the base commit to diff against.
# For pull_request: use base.sha (the merge-base with main/staging).
# For push: use github.event.before (the previous tip of the branch).
# Fallback for new branches (all-zeros SHA): run everything.
if [ "${{ github.event_name }}" = "pull_request" ] && \
[ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "${{ github.event.before }}" ] && \
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
BASE="${{ github.event.before }}"
else else
# New branch or github.event.before unavailable — run everything. echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=new-branch-fallback" >> "$GITHUB_OUTPUT"
exit 0
fi
# GitHub Actions and Gitea Actions both expose github.sha for HEAD.
DIFF=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null)
echo "debug=diff-base=$BASE diff-files=$DIFF" >> "$GITHUB_OUTPUT"
if echo "$DIFF" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.github/workflows/harness-replays\.yml$'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi fi
# ONE job that always runs. Real work is gated per-step on # ONE job that always runs. Real work is gated per-step on
@ -110,17 +91,10 @@ jobs:
run: | run: |
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running." echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)." echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
echo "::notice::Debug: ${{ needs.detect-changes.outputs.debug }}"
- if: needs.detect-changes.outputs.run == 'true' - if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Log what files were detected so future failures include the diff.
- name: Log detected changes
if: needs.detect-changes.outputs.run == 'true'
run: |
echo "::notice::detect-changes debug: ${{ needs.detect-changes.outputs.debug }}"
# github-app-auth sibling-checkout removed 2026-05-07 (#157): # github-app-auth sibling-checkout removed 2026-05-07 (#157):
# the plugin was dropped + Dockerfile.tenant no longer COPYs it. # the plugin was dropped + Dockerfile.tenant no longer COPYs it.
@ -145,17 +119,6 @@ jobs:
# symptom, different root cause: staging still has the in-image # symptom, different root cause: staging still has the in-image
# clone path, hits the auth error directly). # clone path, hits the auth error directly).
# #
# 2026-05-08 sub-finding (#192): the clone step ALSO fails when
# any referenced workspace-template repo is private and the
# AUTO_SYNC_TOKEN bearer (devops-engineer persona) lacks read
# access. Root cause: 5 of 9 workspace-template repos
# (openclaw, codex, crewai, deepagents, gemini-cli) had been
# marked private with no team grant. Resolution: flipped them
# to public per `feedback_oss_first_repo_visibility_default`
# (the OSS surface should be public). Layer-3 (customer-private +
# marketplace third-party repos) tracked separately in
# internal#102.
#
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN # Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
# is the devops-engineer persona PAT, NOT the founder PAT (per # is the devops-engineer persona PAT, NOT the founder PAT (per
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh # `feedback_per_agent_gitea_identity_default`). clone-manifest.sh

View File

@ -1,15 +1,5 @@
name: publish-runtime name: publish-runtime
# DEPRECATED on Gitea Actions — this file is kept for reference only.
# Gitea Actions reads .gitea/workflows/, not .github/workflows/.
# The canonical version is now: .gitea/workflows/publish-runtime.yml
# That port:
# - Drops OIDC trusted publisher (Gitea has no environments/OIDC)
# - Uses PYPI_TOKEN secret instead of gh-action-pypi-publish
# - Uses ${GITHUB_REF#refs/tags/} instead of github.ref_name
# - Drops staging branch trigger (staging branch does not exist)
# - Drops merge_group trigger (Gitea has no merge queue)
#
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/. # Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
# Monorepo workspace/ is the only source-of-truth for runtime code; this # Monorepo workspace/ is the only source-of-truth for runtime code; this
# workflow is the bridge from monorepo edits to the PyPI artifact that # workflow is the bridge from monorepo edits to the PyPI artifact that

View File

@ -284,7 +284,7 @@ cp .env.example .env
./infra/scripts/setup.sh ./infra/scripts/setup.sh
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001), # Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
# and Temporal (:7233 gRPC, :8233 UI) on the shared # and Temporal (:7233 gRPC, :8233 UI) on the shared
# `molecule-core-net` Docker network. Temporal runs with # `molecule-monorepo-net` Docker network. Temporal runs with
# no auth on localhost — dev-only; production must gate it. # no auth on localhost — dev-only; production must gate it.
# #
# Also populates the template/plugin registry by cloning every repo # Also populates the template/plugin registry by cloning every repo

View File

@ -283,7 +283,7 @@ cp .env.example .env
./infra/scripts/setup.sh ./infra/scripts/setup.sh
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001) # 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的 # 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权, # `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。 # 仅用于本地开发;生产环境必须加 mTLS / API Key。
# #
# 同时会根据 manifest.json 拉取所有模板/插件仓库到 # 同时会根据 manifest.json 拉取所有模板/插件仓库到

View File

@ -1,10 +0,0 @@
# Excluded from `docker build` context. Without this, the COPY . . step in
# canvas/Dockerfile clobbers the freshly-installed node_modules with the
# host's (potentially broken / wrong-arch) copy — the @tailwindcss/oxide
# native binary disagreed and broke `next build`.
node_modules
.next
.git
*.log
.env*
!.env.example

View File

@ -1,11 +1,7 @@
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# `npm ci` (not `install`) for lockfile-exact reproducibility. RUN npm install
# `--include=optional` ensures the platform-specific @tailwindcss/oxide
# native binary lands — without it, postcss fails with "Cannot read
# properties of undefined (reading 'All')" at build time.
RUN npm ci --include=optional
COPY . . COPY . .
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
@ -15,7 +11,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
RUN npm run build RUN npm run build
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static

View File

@ -17,24 +17,6 @@ import { dirname, join } from "node:path";
// update one heuristic. Production is unaffected: `output: "standalone"` // update one heuristic. Production is unaffected: `output: "standalone"`
// bakes resolved env into the build, and the marker file isn't shipped. // bakes resolved env into the build, and the marker file isn't shipped.
loadMonorepoEnv(); loadMonorepoEnv();
// Boot-time matched-pair guard for ADMIN_TOKEN / NEXT_PUBLIC_ADMIN_TOKEN.
// When ADMIN_TOKEN is set on the workspace-server (server-side bearer
// gate, wsauth_middleware.go ~L245), the canvas MUST send the matching
// NEXT_PUBLIC_ADMIN_TOKEN as `Authorization: Bearer ...` on every API
// call. If only one is set, every workspace API call 401s silently —
// the canvas hydrates with empty data and the user sees a broken page
// with no console hint about the auth-config mismatch.
//
// Pre-fix the matched-pair contract was descriptive only (a comment in
// .env): future devs/agents could re-misconfigure with one of the two
// unset and silently 401. Closes the post-PR-#174 self-review gap.
//
// Warn-only (not exit) — production canvas Docker images bake these
// vars into the build at image-build time, and a missed pair there
// would still emit the warning at runtime via the standalone server's
// startup. Killing the process on misconfiguration would turn a
// recoverable auth issue into a hard crashloop.
checkAdminTokenPair();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
@ -75,43 +57,6 @@ function loadMonorepoEnv() {
); );
} }
// Boot-time matched-pair guard. Runs after .env has been loaded so the
// check sees the post-load state. The two env vars must be set or
// unset together; one-without-the-other is the silent-401 footgun.
//
// Treats empty string ("") as unset. An explicitly-empty `KEY=` in
// .env counts as set-to-empty in `process.env`, but for auth purposes
// an empty bearer token is equivalent to no token — so both
// `ADMIN_TOKEN=` and an unset ADMIN_TOKEN are equivalent relative to
// the matched-pair invariant.
//
// Returns void; side effect is the console.error warning. Kept as a
// separate function (exported) so a future test can reset env, call
// this, and assert on captured stderr.
export function checkAdminTokenPair(): void {
const serverSet = !!process.env.ADMIN_TOKEN;
const clientSet = !!process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (serverSet === clientSet) return;
// Distinct messages so the operator can tell which half is missing
// — the fix is symmetric (set the other one) but the diagnostic
// mentions which side is currently set so they don't have to grep.
if (serverSet && !clientSet) {
// eslint-disable-next-line no-console
console.error(
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
"canvas will 401 against workspace-server because the bearer header " +
"is never attached. Set both to the same value, or unset both.",
);
} else {
// eslint-disable-next-line no-console
console.error(
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
"workspace-server will reject the bearer because no AdminAuth gate " +
"is configured. Set both to the same value, or unset both.",
);
}
}
function findMonorepoRoot(start: string): string | null { function findMonorepoRoot(start: string): string | null {
let dir = start; let dir = start;
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {

View File

@ -354,7 +354,7 @@ function OrgCTA({ org }: { org: Org }) {
); );
} }
// provisioning / unknown — non-interactive // provisioning / unknown — non-interactive
return <span className="text-sm text-ink-mid">{org.status}</span>; return <span className="text-sm text-ink-soft">{org.status}</span>;
} }
function EmptyState({ banner }: { banner?: React.ReactNode }) { function EmptyState({ banner }: { banner?: React.ReactNode }) {
@ -420,7 +420,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
aria-describedby="org-slug-hint" aria-describedby="org-slug-hint"
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink" className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
/> />
<p id="org-slug-hint" className="mt-1 text-xs text-ink-mid"> <p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
Lowercase letters, numbers, and hyphens only. Cannot be changed later. Lowercase letters, numbers, and hyphens only. Cannot be changed later.
</p> </p>
</div> </div>

View File

@ -56,7 +56,7 @@ export default function Home() {
<div className="fixed inset-0 flex items-center justify-center bg-surface"> <div className="fixed inset-0 flex items-center justify-center bg-surface">
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3"> <div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
<Spinner size="lg" /> <Spinner size="lg" />
<span className="text-xs text-ink-mid">Loading canvas...</span> <span className="text-xs text-ink-soft">Loading canvas...</span>
</div> </div>
</div> </div>
); );
@ -119,11 +119,11 @@ function PlatformDownDiagnostic() {
Most common cause on a dev host: one of those services stopped. Most common cause on a dev host: one of those services stopped.
</p> </p>
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full"> <div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
<div className="text-[10px] uppercase tracking-wider text-ink-mid mb-2">Try first</div> <div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14 <pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
brew services start redis`}</pre> brew services start redis`}</pre>
</div> </div>
<p className="text-[11px] text-ink-mid max-w-lg text-center"> <p className="text-[11px] text-ink-soft max-w-lg text-center">
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment. the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment.
</p> </p>

View File

@ -55,13 +55,13 @@ export default function PricingPage() {
</a> </a>
. .
</p> </p>
<p className="mt-6 text-sm text-ink-mid"> <p className="mt-6 text-sm text-ink-soft">
Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier. Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier.
Enterprise / self-hosted licensing available contact us. Enterprise / self-hosted licensing available contact us.
</p> </p>
</section> </section>
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-mid"> <footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
<p> <p>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "} © {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-ink-mid"> <a href="/legal/terms" className="hover:text-ink-mid">

View File

@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading audit trail</span> <span className="text-xs text-ink-soft">Loading audit trail</span>
</div> </div>
); );
} }
@ -145,7 +145,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${ className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600" ? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60" : "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`} }`}
> >
{f.label} {f.label}
@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{entries.length === 0 ? ( {entries.length === 0 ? (
/* Empty state */ /* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"></span> <span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">No audit events yet</p> <p className="text-sm font-medium text-ink-mid">No audit events yet</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Delegation, decision, gate, and human-in-the-loop events will appear here. Delegation, decision, gate, and human-in-the-loop events will appear here.
</p> </p>
</div> </div>
@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
)} )}
{/* Entry count footer */} {/* Entry count footer */}
<p className="mt-3 text-center text-[9px] text-ink-mid"> <p className="mt-3 text-center text-[9px] text-ink-soft">
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded {entries.length} event{entries.length !== 1 ? "s" : ""} loaded
{cursor ? " · more available" : " · all loaded"} {cursor ? " · more available" : " · all loaded"}
</p> </p>
@ -265,7 +265,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
)} )}
{/* Relative timestamp */} {/* Relative timestamp */}
<span className="shrink-0 text-[9px] text-ink-mid"> <span className="shrink-0 text-[9px] text-ink-soft">
{formatAuditRelativeTime(entry.created_at, now)} {formatAuditRelativeTime(entry.created_at, now)}
</span> </span>
</div> </div>

View File

@ -125,7 +125,7 @@ export function BundleDropZone() {
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center"> <div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2" aria-hidden="true">📦</div> <div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div> <div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
<div className="text-xs text-ink-mid mt-1">.bundle.json files only</div> <div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
</div> </div>
</div> </div>
)} )}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useMemo } from "react";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
@ -187,23 +187,6 @@ function CanvasInner() {
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport(); const { onMoveEnd } = useCanvasViewport();
// Screen-reader announcements — read liveAnnouncement from the store and
// immediately clear it so the same announcement doesn't re-fire on
// re-render. Using a ref avoids a setState loop while keeping the
// effect reactive to new announcement strings.
const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement);
const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement);
const prevAnnouncement = useRef("");
useEffect(() => {
if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) {
prevAnnouncement.current = liveAnnouncement;
// Small delay so the DOM update lands before clearing, giving
// screen readers time to pick up the new text.
const timer = setTimeout(() => clearAnnouncement(""), 500);
return () => clearTimeout(timer);
}
}, [liveAnnouncement, clearAnnouncement]);
// Delete-confirmation lives in the store so the dialog survives ContextMenu // Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's // unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler. // outside-click handler.
@ -343,21 +326,11 @@ function CanvasInner() {
<DropTargetBadge /> <DropTargetBadge />
</ReactFlow> </ReactFlow>
{/* Screen-reader live region announces workspace count on initial load and {/* Screen-reader live region: announces workspace count on canvas load or change */}
live status updates from WebSocket events (online, offline, provisioning, etc.). <div role="status" aria-live="polite" className="sr-only">
The liveAnnouncement text is cleared after the screen reader has had time {nodes.filter((n) => !n.parentId).length === 0
to read it so the same message doesn't re-announce on re-render. */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement || (
nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas" ? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas` : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
)}
</div> </div>
{nodes.length === 0 && <EmptyState />} {nodes.length === 0 && <EmptyState />}

View File

@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button" type="button"
onClick={() => setVisible(false)} onClick={() => setVisible(false)}
aria-label="Close communications panel" aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs" className="text-ink-soft hover:text-ink-mid text-xs"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
@ -268,7 +268,7 @@ export function CommunicationOverlay() {
</div> </div>
</div> </div>
{c.summary && ( {c.summary && (
<div className="text-ink-mid truncate mt-0.5 pl-4">{c.summary}</div> <div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
)} )}
{c.durationMs && ( {c.durationMs && (
<div className="text-ink-mid pl-4">{c.durationMs}ms</div> <div className="text-ink-mid pl-4">{c.durationMs}ms</div>

View File

@ -103,7 +103,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
EC2 console output EC2 console output
</h3> </h3>
{workspaceName && ( {workspaceName && (
<div className="text-[11px] text-ink-mid mt-0.5 truncate max-w-[600px]"> <div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
{workspaceName} {workspaceName}
</div> </div>
)} )}
@ -124,7 +124,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4"> <div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && ( {loading && (
<div className="text-[12px] text-ink-mid" data-testid="console-loading"> <div className="text-[12px] text-ink-soft" data-testid="console-loading">
Loading console output Loading console output
</div> </div>
)} )}

View File

@ -311,7 +311,7 @@ export function ContextMenu() {
aria-hidden="true" aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`} className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/> />
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span> <span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
</div> </div>
</div> </div>

View File

@ -106,7 +106,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Title className="text-sm font-semibold text-ink"> <Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace Conversation Trace
</Dialog.Title> </Dialog.Title>
<p className="text-[10px] text-ink-mid mt-0.5"> <p className="text-[10px] text-ink-soft mt-0.5">
{entries.length} events across all workspaces {entries.length} events across all workspaces
</p> </p>
</div> </div>
@ -114,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button <button
type="button" type="button"
aria-label="Close conversation trace" aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2" className="text-ink-soft hover:text-ink-mid text-lg px-2"
> >
</button> </button>
@ -124,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */} {/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-y-auto px-5 py-4">
{loading && ( {loading && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
Loading trace from all workspaces... Loading trace from all workspaces...
</div> </div>
)} )}
{!loading && entries.length === 0 && ( {!loading && entries.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
No activity found No activity found
</div> </div>
)} )}
@ -250,7 +250,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Message content — show request and/or response */} {/* Message content — show request and/or response */}
{requestText && ( {requestText && (
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto"> <div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-ink-mid uppercase mb-1"> <div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"} {isSend ? "Task" : "Request"}
</div> </div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed"> <div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">

View File

@ -338,7 +338,7 @@ export function CreateWorkspaceButton() {
<Dialog.Title className="text-base font-semibold text-ink mb-1"> <Dialog.Title className="text-base font-semibold text-ink mb-1">
Create Workspace Create Workspace
</Dialog.Title> </Dialog.Title>
<p className="text-xs text-ink-mid mb-5"> <p className="text-xs text-ink-soft mb-5">
Add a new workspace node to the canvas Add a new workspace node to the canvas
</p> </p>
@ -376,7 +376,7 @@ export function CreateWorkspaceButton() {
/> />
<div className="text-xs"> <div className="text-xs">
<div className="text-ink font-medium">External agent (bring your own compute)</div> <div className="text-ink font-medium">External agent (bring your own compute)</div>
<div className="text-ink-mid mt-0.5"> <div className="text-ink-soft mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A. Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div> </div>
</div> </div>
@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide"> <p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
Hermes Provider Hermes Provider
</p> </p>
<p className="text-[11px] text-ink-mid -mt-1"> <p className="text-[11px] text-ink-soft -mt-1">
Choose the AI provider and paste your API key. The key is Choose the AI provider and paste your API key. The key is
stored as an encrypted workspace secret. stored as an encrypted workspace secret.
</p> </p>
@ -534,7 +534,7 @@ export function CreateWorkspaceButton() {
(m) => <option key={m} value={m} />, (m) => <option key={m} value={m} />,
)} )}
</datalist> </datalist>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Slug determines which provider hermes routes to at install time. Slug determines which provider hermes routes to at install time.
</p> </p>
</div> </div>
@ -626,7 +626,7 @@ function InputField({
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`} className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
/> />
{helper && ( {helper && (
<p className="mt-1 text-xs text-ink-mid">{helper}</p> <p className="mt-1 text-xs text-ink-soft">{helper}</p>
)} )}
</div> </div>
); );

View File

@ -129,11 +129,11 @@ export function EmptyState() {
T{t.tier} T{t.tier}
</span> </span>
</div> </div>
<p className="text-[11px] text-ink-mid line-clamp-2 leading-relaxed"> <p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
{t.description || "No description"} {t.description || "No description"}
</p> </p>
{t.skill_count > 0 && ( {t.skill_count > 0 && (
<p className="text-[9px] text-ink-mid mt-1.5"> <p className="text-[9px] text-ink-soft mt-1.5">
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""} {t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
{t.model ? ` · ${t.model}` : ""} {t.model ? ` · ${t.model}` : ""}
</p> </p>
@ -174,10 +174,10 @@ export function EmptyState() {
<div className="mt-5 pt-4 border-t border-line/50"> <div className="mt-5 pt-4 border-t border-line/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid"> <div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
<span>Drag to nest workspaces into teams</span> <span>Drag to nest workspaces into teams</span>
<span className="text-ink-mid">|</span> <span className="text-ink-soft">|</span>
<span>Right-click for actions</span> <span>Right-click for actions</span>
<span className="text-ink-mid">|</span> <span className="text-ink-soft">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-mid font-mono">&#8984;K</kbd> to search</span> <span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">&#8984;K</kbd> to search</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -201,7 +201,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${ className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t tab === t
? "border-accent text-ink" ? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid" : "border-transparent text-ink-soft hover:text-ink-mid"
}`} }`}
> >
{t === "claude" {t === "claude"
@ -335,7 +335,7 @@ function SnippetBlock({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-1"> <div className="flex items-center justify-between pb-1">
<span className="text-xs text-ink-mid">{label}</span> <span className="text-xs text-ink-soft">{label}</span>
<button <button
type="button" type="button"
onClick={onCopy} onClick={onCopy}
@ -366,7 +366,7 @@ function Field({
}) { }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-ink-mid w-36 shrink-0">{label}</span> <span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
<code <code
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`} className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
> >

View File

@ -1,235 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface ShortcutGroup {
title: string;
shortcuts: Array<{ keys: string[]; description: string }>;
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: "Canvas",
shortcuts: [
{
keys: ["Esc"],
description: "Close context menu, clear selection, or deselect",
},
{
keys: ["↑↓←→"],
description: "Nudge selected node 10px; hold Shift for 50px",
},
{
keys: ["Cmd", "↑↓←→"],
description: "Resize selected node (↑↓ height, ←→ width); hold Shift for fine control (2px)",
},
{
keys: ["Enter"],
description: "Descend into selected node's first child",
},
{
keys: ["Shift", "Enter"],
description: "Ascend to selected node's parent",
},
{
keys: ["Cmd", "]"],
description: "Bring selected node forward in z-order",
},
{
keys: ["Cmd", "["],
description: "Send selected node backward in z-order",
},
{
keys: ["Z"],
description: "Zoom to fit the selected team and its sub-workspaces",
},
],
},
{
title: "Navigation",
shortcuts: [
{
keys: ["⌘K"],
description: "Open workspace search",
},
{
keys: ["Palette"],
description: "Open the template palette to deploy a new workspace",
},
{
keys: ["Dbl-click"],
description: "Zoom canvas to fit a team node and all its sub-workspaces",
},
{
keys: ["Right-click"],
description: "Open the workspace context menu",
},
],
},
{
title: "Agent",
shortcuts: [
{
keys: ["Chat"],
description: "Send a message or resume a running task",
},
{
keys: ["Config"],
description: "Edit skills, model, secrets, and runtime settings",
},
{
keys: ["Audit"],
description: "View the activity ledger for the selected workspace",
},
],
},
];
interface Props {
open: boolean;
onClose: () => void;
}
export function KeyboardShortcutsDialog({ open, onClose }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3)
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Keyboard: Escape closes, Tab is trapped
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="keyboard-shortcuts-title"
className="relative bg-surface border border-line rounded-xl shadow-2xl shadow-black/60 max-w-[480px] w-full mx-4 overflow-hidden max-h-[80vh] flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-line shrink-0">
<h2
id="keyboard-shortcuts-title"
className="text-sm font-semibold text-ink"
>
Keyboard Shortcuts
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close keyboard shortcuts"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
×
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-5 space-y-5">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-mid mb-2.5">
{group.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between gap-4"
>
<span className="text-[13px] text-ink-mid">
{shortcut.description}
</span>
<kbd className="flex items-center gap-0.5 shrink-0">
{shortcut.keys.map((k, j) => (
<span key={j} className="flex items-center gap-0.5">
{j > 0 && (
<span className="text-[9px] text-ink-mid mx-0.5">
+
</span>
)}
<span className="inline-flex items-center rounded-md border border-line/70 bg-surface-sunken/70 px-2 py-0.5 text-[11px] font-medium text-ink tabular-nums font-mono">
{k}
</span>
</span>
))}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-line bg-surface-sunken/30 shrink-0">
<p className="text-[10px] text-ink-mid text-center">
Press{" "}
<kbd className="inline-flex items-center rounded border border-line/70 bg-surface-sunken/70 px-1.5 py-0.5 text-[10px] font-medium text-ink font-mono">
Esc
</kbd>{" "}
to close
</p>
</div>
</div>
</div>,
document.body
);
}

View File

@ -97,7 +97,7 @@ export function Legend() {
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min). // 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before // Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger. // — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors" className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
> >
× ×
</button> </button>
@ -105,7 +105,7 @@ export function Legend() {
{/* Status */} {/* Status */}
<div className="mb-2"> <div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Status</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_STATUSES.map((s) => ( {LEGEND_STATUSES.map((s) => (
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} /> <StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
@ -115,7 +115,7 @@ export function Legend() {
{/* Tiers */} {/* Tiers */}
<div className="mb-2"> <div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Tier</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_TIERS.map(({ tier, label }) => ( {LEGEND_TIERS.map(({ tier, label }) => (
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} /> <TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
@ -125,7 +125,7 @@ export function Legend() {
{/* Communication */} {/* Communication */}
<div> <div>
<div className="text-[11px] text-ink-mid font-medium mb-1">Communication</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" /> <CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
<CommItem icon="↙" color="text-accent" label="A2A In" /> <CommItem icon="↙" color="text-accent" label="A2A In" />

View File

@ -288,7 +288,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
if (loading && entries.length === 0 && !error && !pluginUnavailable) { if (loading && entries.length === 0 && !error && !pluginUnavailable) {
return ( return (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading memories</span> <span className="text-xs text-ink-soft">Loading memories</span>
</div> </div>
); );
} }
@ -311,7 +311,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Namespace dropdown */} {/* Namespace dropdown */}
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2"> <div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-mid shrink-0"> <label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
Namespace: Namespace:
</label> </label>
<select <select
@ -337,7 +337,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
height="12" height="12"
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="none" fill="none"
className="absolute left-2.5 text-ink-mid pointer-events-none shrink-0" className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
aria-hidden="true" aria-hidden="true"
> >
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" /> <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery(''); setDebouncedQuery('');
}} }}
aria-label="Clear search" aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none" className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
> >
× ×
</button> </button>
@ -370,7 +370,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Toolbar */} {/* Toolbar */}
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0"> <div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-ink-mid"> <span className="text-[11px] text-ink-soft">
{debouncedQuery {debouncedQuery
? `${entries.length} result${entries.length !== 1 ? 's' : ''}` ? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
: entries.length === 1 : entries.length === 1
@ -446,11 +446,11 @@ function EmptyState({
// mirror it so the operator sees both signals. // mirror it so the operator sees both signals.
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p> <p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
See banner above for the operator-side fix. See banner above for the operator-side fix.
</p> </p>
</div> </div>
@ -459,11 +459,11 @@ function EmptyState({
if (query) { if (query) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">No memories match your search</p> <p className="text-sm font-medium text-ink-mid">No memories match your search</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Try a different query or clear the search. Try a different query or clear the search.
</p> </p>
</div> </div>
@ -471,11 +471,11 @@ function EmptyState({
} }
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">No memories yet</p> <p className="text-sm font-medium text-ink-mid">No memories yet</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
Agents commit memories via MCP tools (commit_memory, commit_summary). They Agents commit memories via MCP tools (commit_memory, commit_summary). They
appear here once written. appear here once written.
</p> </p>
@ -558,7 +558,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Namespace tag */} {/* Namespace tag */}
<span <span
className="text-[9px] shrink-0 font-mono text-ink-mid truncate max-w-[100px]" className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
title={entry.namespace} title={entry.namespace}
> >
{entry.namespace} {entry.namespace}
@ -598,10 +598,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
)} )}
<span className="text-[9px] text-ink-mid shrink-0"> <span className="text-[9px] text-ink-soft shrink-0">
{formatRelativeTime(entry.created_at)} {formatRelativeTime(entry.created_at)}
</span> </span>
<span className="text-[9px] text-ink-mid shrink-0" aria-hidden="true"> <span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
{expanded ? '▼' : '▶'} {expanded ? '▼' : '▶'}
</span> </span>
</button> </button>
@ -618,7 +618,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{entry.content} {entry.content}
</pre> </pre>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
Created: {new Date(entry.created_at).toLocaleString()} Created: {new Date(entry.created_at).toLocaleString()}
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`} {entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
</span> </span>

View File

@ -421,7 +421,7 @@ function ProviderPickerModal({
<div className="text-[11px] text-ink-mid font-medium"> <div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)} {getKeyLabel(entry.key)}
</div> </div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div> <div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div> </div>
{entry.saved && ( {entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1"> <span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
@ -675,7 +675,7 @@ function AllKeysModal({
<div className="text-[11px] text-ink-mid font-medium"> <div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)} {getKeyLabel(entry.key)}
</div> </div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div> <div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div> </div>
{entry.saved && ( {entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1"> <span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">

View File

@ -247,7 +247,7 @@ export function OrgImportPreflightModal({
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink"> <h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
Deploy {orgName} Deploy {orgName}
</h2> </h2>
<p className="mt-0.5 text-[11px] text-ink-mid"> <p className="mt-0.5 text-[11px] text-ink-soft">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}. {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import. Review the credentials needed before import.
</p> </p>
@ -400,7 +400,7 @@ function StrictEnvRow({
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5"> <li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
<code <code
className={`text-[11px] font-mono flex-1 ${ className={`text-[11px] font-mono flex-1 ${
configured ? "text-ink-mid line-through" : "text-ink" configured ? "text-ink-soft line-through" : "text-ink"
}`} }`}
> >
{envKey} {envKey}
@ -492,7 +492,7 @@ function AnyOfEnvGroup({
> >
<code <code
className={`text-[11px] font-mono flex-1 ${ className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-ink-mid line-through" : "text-ink" isConfigured ? "text-ink-soft line-through" : "text-ink"
}`} }`}
> >
{m} {m}

View File

@ -356,7 +356,7 @@ export function ProviderModelSelector({
<div> <div>
<label <label
htmlFor={providerSelectId} htmlFor={providerSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block" className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
> >
Provider <span aria-hidden="true" className="text-bad">*</span> Provider <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span> <span className="sr-only"> (required)</span>
@ -382,13 +382,13 @@ export function ProviderModelSelector({
{selected?.tooltip && ( {selected?.tooltip && (
<p <p
id={`${providerSelectId}-help`} id={`${providerSelectId}-help`}
className="text-[9px] text-ink-mid mt-1 leading-relaxed" className="text-[9px] text-ink-soft mt-1 leading-relaxed"
> >
{selected.tooltip} {selected.tooltip}
</p> </p>
)} )}
{selected && selected.envVars.length > 0 && ( {selected && selected.envVars.length > 0 && (
<p className="text-[9px] text-ink-mid mt-0.5 font-mono"> <p className="text-[9px] text-ink-soft mt-0.5 font-mono">
requires: {selected.envVars.join(", ")} requires: {selected.envVars.join(", ")}
</p> </p>
)} )}
@ -397,7 +397,7 @@ export function ProviderModelSelector({
<div> <div>
<label <label
htmlFor={modelSelectId} htmlFor={modelSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block" className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
> >
Model <span aria-hidden="true" className="text-bad">*</span> Model <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span> <span className="sr-only"> (required)</span>
@ -422,7 +422,7 @@ export function ProviderModelSelector({
data-testid="model-input" data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50" className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/> />
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed"> <p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
{selected?.wildcard {selected?.wildcard
? wildcardHelpText(selected) ? wildcardHelpText(selected)
: "Free-text model id. Make sure the provider can resolve it."} : "Free-text model id. Make sure the provider can resolve it."}

View File

@ -157,7 +157,7 @@ export function PurchaseSuccessModal() {
</div> </div>
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50"> <div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-mid"> <span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
auto-dismiss · {AUTO_DISMISS_MS / 1000}s auto-dismiss · {AUTO_DISMISS_MS / 1000}s
</span> </span>
<button <button

View File

@ -104,7 +104,7 @@ export function SearchDialog() {
> >
{/* Search input */} {/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40"> <div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-mid" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" /> <circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg> </svg>
@ -156,7 +156,7 @@ export function SearchDialog() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-ink truncate">{node.data.name}</div> <div className="text-sm text-ink truncate">{node.data.name}</div>
{node.data.role && ( {node.data.role && (
<div className="text-[10px] text-ink-mid truncate">{node.data.role}</div> <div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
)} )}
</div> </div>
<span <span

View File

@ -165,12 +165,12 @@ export function SidePanel() {
</h2> </h2>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
{node.data.role && ( {node.data.role && (
<span className="text-[10px] text-ink-mid truncate"> <span className="text-[10px] text-ink-soft truncate">
{node.data.role} {node.data.role}
</span> </span>
)} )}
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${ <span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
isOnline ? "text-good bg-emerald-950/30" : "text-ink-mid bg-surface-card/50" isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
}`}> }`}>
T{node.data.tier} T{node.data.tier}
</span> </span>
@ -181,7 +181,7 @@ export function SidePanel() {
type="button" type="button"
onClick={() => selectNode(null)} onClick={() => selectNode(null)}
aria-label="Close workspace panel" aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors" className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
> >
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@ -296,7 +296,7 @@ export function SidePanel() {
{/* Footer — workspace ID */} {/* Footer — workspace ID */}
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20"> <div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all"> <span className="text-[9px] font-mono text-ink-soft select-all">
{selectedNodeId} {selectedNodeId}
</span> </span>
</div> </div>

View File

@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="org-templates-body" aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors" className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
> >
<span <span
aria-hidden="true" aria-hidden="true"
@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
</span> </span>
Org Templates Org Templates
{orgs.length > 0 && ( {orgs.length > 0 && (
<span className="text-ink-mid normal-case tracking-normal"> <span className="text-ink-soft normal-case tracking-normal">
({orgs.length}) ({orgs.length})
</span> </span>
)} )}
@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button" type="button"
onClick={loadOrgs} onClick={loadOrgs}
aria-label="Refresh org templates" aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid" className="text-[10px] text-ink-soft hover:text-ink-mid"
> >
</button> </button>
@ -264,14 +264,14 @@ export function OrgTemplatesSection() {
{expanded && ( {expanded && (
<div id="org-templates-body" className="space-y-2"> <div id="org-templates-body" className="space-y-2">
{loading && ( {loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-mid"> <div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
<Spinner size="sm" /> <Spinner size="sm" />
Loading Loading
</div> </div>
)} )}
{!loading && orgs.length === 0 && ( {!loading && orgs.length === 0 && (
<div className="text-[10px] text-ink-mid"> <div className="text-[10px] text-ink-soft">
No org templates in <code>org-templates/</code> No org templates in <code>org-templates/</code>
</div> </div>
)} )}
@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
</span> </span>
</div> </div>
{o.description && ( {o.description && (
<p className="text-[10px] text-ink-mid mb-2.5 line-clamp-2 leading-relaxed"> <p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
{o.description} {o.description}
</p> </p>
)} )}
@ -499,7 +499,7 @@ export function TemplatePalette() {
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40"> <div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-line/60"> <div className="px-4 pt-14 pb-3 border-b border-line/60">
<h2 className="text-sm font-semibold text-ink">Templates</h2> <h2 className="text-sm font-semibold text-ink">Templates</h2>
<p className="text-[10px] text-ink-mid mt-0.5">Click to deploy a workspace</p> <p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
@ -509,14 +509,14 @@ export function TemplatePalette() {
<OrgTemplatesSection /> <OrgTemplatesSection />
{loading && ( {loading && (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid text-center py-8"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
<Spinner /> <Spinner />
Loading Loading
</div> </div>
)} )}
{!loading && templates.length === 0 && ( {!loading && templates.length === 0 && (
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8"> <div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
No templates found in<br />workspace-configs-templates/ No templates found in<br />workspace-configs-templates/
</div> </div>
)} )}
@ -549,7 +549,7 @@ export function TemplatePalette() {
</div> </div>
{t.description && ( {t.description && (
<p className="text-[10px] text-ink-mid mb-2 line-clamp-2 leading-relaxed"> <p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
{t.description} {t.description}
</p> </p>
)} )}
@ -562,7 +562,7 @@ export function TemplatePalette() {
</span> </span>
))} ))}
{t.skills.length > 3 && ( {t.skills.length > 3 && (
<span className="text-[8px] text-ink-mid">+{t.skills.length - 3}</span> <span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
)} )}
</div> </div>
)} )}
@ -580,7 +580,7 @@ export function TemplatePalette() {
<button <button
type="button" type="button"
onClick={loadTemplates} onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block" className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
> >
Refresh templates Refresh templates
</button> </button>

View File

@ -124,7 +124,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</a> </a>
. Click agree to continue. . Click agree to continue.
</p> </p>
<p className="mt-3 text-xs text-ink-mid"> <p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States). By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p> </p>
</div> </div>

View File

@ -57,7 +57,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
"flex h-6 w-6 items-center justify-center rounded transition-colors " + "flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active (active
? "bg-surface-elevated text-ink shadow-sm" ? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid") : "text-ink-soft hover:text-ink-mid")
} }
> >
<svg <svg

View File

@ -9,7 +9,6 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { showToast } from "@/components/Toaster"; import { showToast } from "@/components/Toaster";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { statusDotClass } from "@/lib/design-tokens"; import { statusDotClass } from "@/lib/design-tokens";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
export function Toolbar() { export function Toolbar() {
const nodes = useCanvasStore((s) => s.nodes); const nodes = useCanvasStore((s) => s.nodes);
@ -34,7 +33,6 @@ export function Toolbar() {
const [restartingAll, setRestartingAll] = useState(false); const [restartingAll, setRestartingAll] = useState(false);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const helpRef = useRef<HTMLDivElement>(null); const helpRef = useRef<HTMLDivElement>(null);
// Suppress toast on the very first connect at page load; only fire on reconnects. // Suppress toast on the very first connect at page load; only fire on reconnects.
@ -129,29 +127,6 @@ export function Toolbar() {
}; };
}, []); }, []);
// Global ? shortcut opens the shortcuts dialog (mirrors the help button).
// Skip when the user is typing in an input so ? in a text field doesn't
// steal focus. Also skip when a modal/dialog is already open.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "?") return;
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
if (inInput) return;
// Don't fire when a modal/dialog is already mounted (canvas modals,
// side panel, etc. use z-50 or above).
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
setShortcutsOpen(true);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return ( return (
<div <div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200" className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
@ -346,14 +321,6 @@ export function Toolbar() {
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." /> <HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." /> <HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
</div> </div>
{/* Link to the full keyboard shortcuts dialog */}
<button
type="button"
onClick={() => { setHelpOpen(false); setShortcutsOpen(true); }}
className="mt-3 w-full text-center text-[10px] text-ink-mid hover:text-accent transition-colors focus:outline-none focus-visible:underline"
>
See all shortcuts
</button>
</div> </div>
)} )}
</div> </div>
@ -373,11 +340,6 @@ export function Toolbar() {
onConfirm={restartAll} onConfirm={restartAll}
onCancel={() => setRestartConfirmOpen(false)} onCancel={() => setRestartConfirmOpen(false)}
/> />
<KeyboardShortcutsDialog
open={shortcutsOpen}
onClose={() => setShortcutsOpen(false)}
/>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useMemo, type KeyboardEvent } from "react"; import { useCallback, useMemo } from "react";
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react"; import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology"; import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
@ -191,23 +191,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
tabIndex={0} className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
role="button"
aria-label={`Extract ${data.name} from its parent (Enter or Space)`}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
// Keyboard accessibility for edge anchors: pressing Enter/Space on
// the top handle extracts this node from its current parent,
// moving it to the root level. Mirrors the Figma/Excalidraw
// pattern of using the connector dot as a keyboard affordance.
if (data.parentId) {
void nestNode(id, null);
}
}
}}
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
/> />
<div className="relative px-3.5 py-2.5"> <div className="relative px-3.5 py-2.5">
@ -374,23 +358,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
tabIndex={0} className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
role="button"
aria-label={`Nest selected workspace inside ${data.name} (Enter or Space)`}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
// Keyboard accessibility for edge anchors: pressing Enter/Space on
// the bottom handle nests the currently-selected node as a child
// of this node. Requires another node to be selected first.
const selected = selectedNodeId;
if (selected && selected !== id) {
void nestNode(selected, id);
}
}
}}
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
/> />
</div> </div>
</> </>

View File

@ -55,7 +55,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
</h4> </h4>
{!loading && metrics && ( {!loading && metrics && (
<span <span
className="text-[10px] text-ink-mid font-mono" className="text-[10px] text-ink-soft font-mono"
data-testid="usage-period" data-testid="usage-period"
> >
{formatPeriod(metrics.period_start, metrics.period_end)} {formatPeriod(metrics.period_start, metrics.period_end)}
@ -131,7 +131,7 @@ function StatRow({
}) { }) {
return ( return (
<div className="flex justify-between items-center" data-testid={testId}> <div className="flex justify-between items-center" data-testid={testId}>
<span className="text-xs text-ink-mid">{label}</span> <span className="text-xs text-ink-soft">{label}</span>
<span className="text-xs text-ink-mid font-mono">{value}</span> <span className="text-xs text-ink-mid font-mono">{value}</span>
</div> </div>
); );

View File

@ -1,285 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ApprovalBanner component.
*
* Covers: renders nothing when no approvals, polls /approvals/pending,
* shows approval cards, approve/deny decisions, toast notifications.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
id: string;
workspace_id: string;
workspace_name: string;
action: string;
reason: string | null;
status: string;
created_at: string;
} => ({
id,
workspace_id: workspaceId,
workspace_name: "Test Workspace",
action: "Run code execution",
reason: "Requires human approval due to workspace policy",
status: "pending",
created_at: "2026-05-10T10:00:00Z",
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
it("renders nothing when there are no pending approvals", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("does not render any approve/deny buttons when list is empty", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
});
});
describe("ApprovalBanner — renders approval cards", () => {
it("renders an alert card for each pending approval", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(2);
});
it("displays the workspace name and action text", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Test Workspace needs approval")).toBeTruthy();
expect(screen.getByText("Run code execution")).toBeTruthy();
});
it("displays the reason when present", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText(/Requires human approval/i)).toBeTruthy();
});
it("omits the reason div when reason is null", async () => {
const approval = pendingApproval("a1");
approval.reason = null;
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByText(/Requires human approval/i)).toBeNull();
});
it("renders both Approve and Deny buttons per card", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /deny/i })).toBeTruthy();
});
it("has aria-live=assertive on the alert container", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alert = screen.getByRole("alert");
expect(alert.getAttribute("aria-live")).toBe("assertive");
});
});
describe("ApprovalBanner — polling", () => {
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
clearIntervalSpy.mockRestore();
});
it("clears the polling interval on unmount", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
const { unmount } = render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
describe("ApprovalBanner — decisions", () => {
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "approved", decided_by: "human" }
);
});
});
it("calls POST with decision=denied on Deny click", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "denied", decided_by: "human" }
);
});
});
it("removes the card from state after a successful decision", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// One alert initially
expect(screen.getAllByRole("alert")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(screen.queryByRole("alert")).toBeNull();
});
});
it("shows a success toast on approve", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Approved", "success");
});
});
it("shows an info toast on deny", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Denied", "info");
});
});
it("shows an error toast when POST fails", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
});
});
it("keeps the card visible when the POST fails", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
// Card still shown because the request failed
expect(screen.getByRole("alert")).toBeTruthy();
});
});
});
describe("ApprovalBanner — handles empty list from server", () => {
it("shows nothing when the API returns an empty array on first poll", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
});

View File

@ -1,317 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for BundleDropZone component.
*
* Covers: drag-over/drag-leave state, drop of valid/invalid files,
* keyboard file input, import success, import error, auto-clear timeout.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BundleDropZone } from "../BundleDropZone";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
post: vi.fn(),
},
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
// ─── Test helper ──────────────────────────────────────────────────────────────
function makeBundle(name = "test-workspace"): File {
const content = JSON.stringify({
name,
tier: 2,
skills: [],
config: {},
});
return new File([content], "test.bundle.json", {
type: "application/json",
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
});
it("renders the keyboard-accessible import button with aria-label", () => {
render(<BundleDropZone />);
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
describe("BundleDropZone — drag state", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the drop overlay when a file is dragged over", () => {
render(<BundleDropZone />);
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
expect(overlay?.className).toContain("fixed");
// Simulate drag-over on the invisible drop zone
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
if (zone) {
fireEvent.dragOver(zone);
} else {
// Fallback: dispatch on the component's outer div
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
if (container) {
fireEvent.dragOver(container);
}
}
});
it("hides the drop overlay when not dragging", () => {
render(<BundleDropZone />);
// By default (no drag), the overlay should not be visible
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
});
});
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
it("triggers the hidden file input when the import button is clicked", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const clickSpy = vi.spyOn(input, "click");
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
expect(clickSpy).toHaveBeenCalled();
});
it("processes a selected file when the file input changes", async () => {
vi.useFakeTimers();
const postMock = vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Imported Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
value: [file],
writable: false,
});
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(postMock).toHaveBeenCalledWith(
"/bundles/import",
expect.objectContaining({ name: "My Bundle" })
);
vi.useRealTimers();
});
});
describe("BundleDropZone — import success", () => {
it("shows success toast after successful import", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "My Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Success toast should be visible
expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
// Toast auto-clears after 4000ms
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByRole("status")).toBeNull();
vi.useRealTimers();
});
it("clears the result toast after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Timed Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4500);
});
expect(screen.queryByText(/timed workspace/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — import error", () => {
it("shows error toast when the API call fails", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
vi.useRealTimers();
});
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
// Error clears after 3000ms
await act(async () => {
vi.advanceTimersByTime(3500);
});
expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
vi.useRealTimers();
});
it("clears error after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/network error/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText(/network error/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — importing state", () => {
it("shows 'Importing bundle...' status while API call is in flight", async () => {
vi.useFakeTimers();
let resolve: (v: unknown) => void;
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
// Advance timer to allow the state update to flush
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("Importing bundle...")).toBeTruthy();
expect(screen.getByRole("status")).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(500);
});
vi.useRealTimers();
});
});
describe("BundleDropZone — file input reset", () => {
it("resets the file input value after processing so the same file can be re-selected", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Reset Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// The component calls e.target.value = "" after processing
expect(input.value).toBe("");
vi.useRealTimers();
});
});

View File

@ -1,90 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { KeyboardShortcutsDialog } from "../KeyboardShortcutsDialog";
afterEach(cleanup);
const onCloseMock = vi.fn();
beforeEach(() => {
onCloseMock.mockReset();
});
describe("KeyboardShortcutsDialog — a11y render", () => {
it("renders with role=dialog and aria-modal=true when open", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the dialog title", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
// The labelledby should reference the h2 with id="keyboard-shortcuts-title"
const title = document.getElementById(labelledby!);
expect(title?.textContent).toMatch(/keyboard shortcuts/i);
});
it("does not render when open=false", () => {
render(<KeyboardShortcutsDialog open={false} onClose={onCloseMock} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("calls onClose when Escape is pressed", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it("focuses the first focusable element (close button) when dialog opens", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
// The component uses requestAnimationFrame to move focus; wait for it to settle.
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const closeBtn = screen.getByRole("button", { name: /close/i });
expect(document.activeElement).toBe(closeBtn);
});
it("traps Tab focus within the dialog", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Collect all focusable elements inside the dialog
const focusableSelectors =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusableEls = Array.from(
dialog.querySelectorAll<HTMLElement>(focusableSelectors)
);
expect(focusableEls.length).toBeGreaterThan(0);
const onlyFocusable = focusableEls[0];
act(() => { onlyFocusable.focus(); });
// Simulate Tab keydown. The dialog's handler should call preventDefault()
// to stop focus leaving the dialog. Verify by checking the event was
// handled (focus remains on the only focusable element).
let tabWasIntercepted = false;
const tabHandler = (e: KeyboardEvent) => {
if (e.key === "Tab") tabWasIntercepted = e.defaultPrevented;
};
window.addEventListener("keydown", tabHandler);
act(() => {
fireEvent.keyDown(onlyFocusable, { key: "Tab", shiftKey: false });
});
expect(tabWasIntercepted).toBe(true);
window.removeEventListener("keydown", tabHandler);
});
});

View File

@ -1,185 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Legend component.
*
* Covers: open/closed state, localStorage persistence, palette-offset
* positioning, status/tier/comm items rendering.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Legend } from "../Legend";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock localStorage ────────────────────────────────────────────────────────
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: () => { store = {}; },
getStore: () => store,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// ─── Mock canvas store ────────────────────────────────────────────────────────
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(),
}));
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Legend — initial render (localStorage open)", () => {
it("renders the legend panel when localStorage has no saved preference", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the legend panel when localStorage has open=1", () => {
localStorageMock.getItem.mockReturnValueOnce("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the collapsed pill when localStorage has open=0", () => {
localStorageMock.getItem.mockReturnValueOnce("0");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Collapsed pill shows "ⓘ Legend"
expect(screen.getByText("Legend")).toBeTruthy();
// Hide button should not be in the open panel
expect(screen.queryByTitle("Hide legend")).toBeNull();
});
});
describe("Legend — open panel content", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
});
it("renders the Status section with status items", () => {
render(<Legend />);
expect(screen.getByText("Status")).toBeTruthy();
// All statuses from LEGEND_STATUSES
expect(screen.getByText("Online")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
});
it("renders the Tier section", () => {
render(<Legend />);
expect(screen.getByText("Tier")).toBeTruthy();
expect(screen.getByText("Sandboxed")).toBeTruthy();
expect(screen.getByText("Standard")).toBeTruthy();
expect(screen.getByText("Privileged")).toBeTruthy();
expect(screen.getByText("Full Access")).toBeTruthy();
});
it("renders the Communication section", () => {
render(<Legend />);
expect(screen.getByText("Communication")).toBeTruthy();
expect(screen.getByText("A2A Out")).toBeTruthy();
expect(screen.getByText("A2A In")).toBeTruthy();
expect(screen.getByText("Task")).toBeTruthy();
expect(screen.getByText("Error")).toBeTruthy();
});
it("renders the hide button", () => {
render(<Legend />);
expect(screen.getByTitle("Hide legend")).toBeTruthy();
});
});
describe("Legend — close and reopen", () => {
it("closes when the hide button is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
// localStorage should be updated to "0"
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"molecule.legend.open",
"0"
);
});
it("reopens when the collapsed pill is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Initially open — close it
fireEvent.click(screen.getByTitle("Hide legend"));
// Collapsed pill appears
expect(screen.getByTitle("Show legend")).toBeTruthy();
// Reopen
fireEvent.click(screen.getByTitle("Show legend"));
expect(localStorageMock.setItem).toHaveBeenLastCalledWith(
"molecule.legend.open",
"1"
);
});
});
describe("Legend — palette offset positioning", () => {
it("uses left-4 when template palette is NOT open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-4");
});
it("uses left-[296px] when template palette IS open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-[296px]");
});
});
describe("Legend — aria attributes", () => {
it("the hide button has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const hideBtn = screen.getByTitle("Hide legend");
expect(hideBtn.getAttribute("aria-label")).toBe("Hide legend");
});
it("the show legend pill has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
const pill = screen.getByTitle("Show legend");
expect(pill.getAttribute("aria-label")).toBe("Show legend");
});
});

View File

@ -1,100 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for StatusDot the small coloured indicator rendered inside
* workspace cards to convey runtime status (online/offline/degraded/etc.).
*
* Coverage:
* - Renders for every known status in STATUS_CONFIG
* - Unknown status falls back to bg-zinc-500
* - size prop (sm/md) applies the correct Tailwind dimension class
* - aria-hidden="true" and role="img" for accessibility
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*/
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-emerald-400");
expect(dot.className).toContain("shadow-emerald-400/50");
expect(dot.getAttribute("aria-hidden")).toBe("true");
});
it("renders with offline status", () => {
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot.className).not.toContain("shadow-");
});
it("renders with degraded status", () => {
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-sky-400");
expect(dot.className).toContain("motion-safe:animate-pulse");
expect(dot.className).toContain("shadow-sky-400/50");
});
it("falls back to bg-zinc-500 for unknown status", () => {
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
});
});
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2.5");
expect(dot.className).toContain("h-2.5");
});
});
describe("StatusDot — accessibility", () => {
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
render(<StatusDot status="online" />);
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
});
});

View File

@ -1,222 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TermsGate component.
*
* Covers: loading accepted (already agreed), loading pending (show
* modal), 401 accepted (not signed in), error state, accept flow,
* focus management (WCAG 2.4.3), and modal accessibility.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { TermsGate } from "../TermsGate";
// PLATFORM_URL is imported from @/lib/api; we mock it via module mock
vi.mock("@/lib/api", () => ({
PLATFORM_URL: "https://app.example.com",
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function mockFetch(res: Response) {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
}
async function resolveFetch(res: Response) {
await act(async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("TermsGate — loading → accepted", () => {
it("renders children immediately (loading state)", () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
// Children are always rendered (TermsGate does not hide them)
expect(screen.getByTestId("children")).toBeTruthy();
});
it("shows no dialog when server returns accepted=true", async () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("shows no dialog when server returns 401 (not signed in)", async () => {
mockFetch(new Response(null, { status: 401 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
});
describe("TermsGate — pending state → modal", () => {
it("shows the terms dialog when server returns accepted=false", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
it("dialog has aria-modal=true and correct labelling", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div>App content</div>
</TermsGate>
);
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-labelledby")).toBeTruthy();
const title = document.getElementById(dialog.getAttribute("aria-labelledby")!);
expect(title?.textContent).toMatch(/terms/i);
});
it("dialog body contains the terms text", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByText(/Terms of Service/i)).toBeTruthy();
expect(screen.getByText(/Privacy Policy/i)).toBeTruthy();
expect(screen.getByText(/AWS us-east-2/i)).toBeTruthy();
});
it("the I agree button is present", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByRole("button", { name: /i agree/i })).toBeTruthy();
});
it("links to terms and privacy policy have correct hrefs", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const links = screen.getAllByRole("link");
const hrefs = links.map((l) => l.getAttribute("href"));
expect(hrefs).toContain("/legal/terms");
expect(hrefs).toContain("/legal/privacy");
});
});
describe("TermsGate — focus management (WCAG 2.4.3)", () => {
it("moves focus to the I agree button when modal opens", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Focus is moved via requestAnimationFrame — wait a tick
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const agreeBtn = screen.getByRole("button", { name: /i agree/i });
expect(document.activeElement).toBe(agreeBtn);
});
});
describe("TermsGate — accept flow", () => {
it("calls POST /cp/auth/accept-terms and closes dialog on success", async () => {
// First: terms-status → pending
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
// Second: accept-terms → 200
const postMock = mockFetch(new Response(null, { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
// Check POST was called
const calls = vi.mocked(global.fetch).mock.calls;
expect(calls.some(
([url, opts]) =>
(url as string).includes("/accept-terms") &&
(opts as RequestInit).method === "POST"
)).toBe(true);
});
it("shows error message and keeps modal open when accept fails", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
mockFetch(new Response("Internal Server Error", { status: 500 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
// Dialog is still open
expect(screen.getByRole("dialog")).toBeTruthy();
});
it.skip("disables the button while submitting (requires fake-timers around fireEvent.click)", async () => {
// This test requires vi.useFakeTimers() + act(() => { fireEvent.click(btn); vi.runAllTimers(); })
// to synchronously advance through the async boundary between click and fetch initiation.
// The current test structure fires the fetch before click, so this is skipped pending
// a refactor of the component to not initiate fetch synchronously on user gesture.
});
});
describe("TermsGate — error state", () => {
it("shows an error alert when terms-status fetch fails with non-401", async () => {
mockFetch(new Response("Gateway Timeout", { status: 504 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
});
it("error alert contains the status code", async () => {
mockFetch(new Response(null, { status: 503 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
expect(screen.getByRole("alert").textContent).toMatch(/503/);
});
});
describe("TermsGate — children always rendered", () => {
it("renders children even when modal is shown (does not gate them)", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children-visible">Behind the modal</div>
</TermsGate>
);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByTestId("children-visible")).toBeTruthy();
});
});

View File

@ -1,146 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ThemeToggle component.
*
* Covers: renders all three options, aria radiogroup semantics,
* aria-checked per option, setTheme calls on click, custom className prop.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeToggle } from "../ThemeToggle";
import * as themeProvider from "@/lib/theme-provider";
// ─── Mock theme provider ───────────────────────────────────────────────────────
const mockSetTheme = vi.fn();
vi.mock("@/lib/theme-provider", () => ({
useTheme: vi.fn(() => ({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
})),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ThemeToggle — render", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("renders a radiogroup with aria-label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radiogroup", { name: "Theme preference" })).toBeTruthy();
});
it("renders three radio buttons", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(3);
});
it("has aria-checked=true on the active option", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[2].getAttribute("aria-checked")).toBe("true"); // dark is third
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light is first
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system is second
});
it("marks 'light' as active when theme=light", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("true"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("marks 'system' as active when theme=system", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "system",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("true"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("has aria-label on each button matching the option label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radio", { name: "Light" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "System" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Dark" })).toBeTruthy();
});
});
describe("ThemeToggle — interaction", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("calls setTheme with 'light' when light button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("calls setTheme with 'system' when system button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "System" }));
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
it("calls setTheme with 'dark' when dark button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Dark" }));
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("calls setTheme only once per click", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledTimes(1);
});
});
describe("ThemeToggle — className prop", () => {
it("passes custom className to the radiogroup", () => {
render(<ThemeToggle className="my-custom-class" />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("my-custom-class");
});
it("applies default className when none provided", () => {
render(<ThemeToggle />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("inline-flex");
});
});

View File

@ -1,235 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Tooltip component.
*
* Covers: portal rendering, 400ms hover delay, keyboard focus reveal,
* Esc dismiss, no render when text is empty.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Tooltip } from "../Tooltip";
afterEach(cleanup);
describe("Tooltip — render", () => {
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
<button type="button">Hover me</button>
</Tooltip>
);
expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
// Tooltip portal is not yet in the DOM (no timer fires on mount)
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not render the tooltip portal when text is empty string", () => {
render(
<Tooltip text="">
<button type="button">Hover me</button>
</Tooltip>
);
// Move mouse over trigger
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("mounts the tooltip into a portal attached to document.body", () => {
render(
<Tooltip text="Portal tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Simulate mouse enter → 400ms delay → tooltip renders
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
});
});
describe("Tooltip — hover delay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("does NOT show tooltip before the 400ms delay expires", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows tooltip after 400ms hover delay", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
it("hides tooltip immediately on mouse leave (clears pending timer)", () => {
render(
<Tooltip text="Cleared tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// Still not shown because mouseLeave cancelled the timer
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not show on a second mouseEnter after mouseLeave", () => {
render(
<Tooltip text="Re-show tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Re-enter
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
});
describe("Tooltip — keyboard focus reveal", () => {
it("shows tooltip on focus without needing the hover timer", () => {
vi.useFakeTimers();
render(
<Tooltip text="Keyboard tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
// No timer needed — onFocus shows immediately
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
it("hides tooltip on blur", () => {
vi.useFakeTimers();
render(
<Tooltip text="Blur tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
btn.blur();
});
expect(screen.queryByRole("tooltip")).toBeNull();
vi.useRealTimers();
});
});
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
it("dismisses tooltip on Escape without blurring the trigger", () => {
vi.useFakeTimers();
render(
<Tooltip text="Esc dismiss tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
expect(document.activeElement).toBe(btn);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Trigger is still focused (Esc dismisses tooltip but does not blur)
expect(document.activeElement).toBe(btn);
vi.useRealTimers();
});
it("does nothing on non-Escape keys while tooltip is open", () => {
vi.useFakeTimers();
render(
<Tooltip text="Non-Escape key">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Enter" });
});
// Tooltip still visible
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
});
describe("Tooltip — aria-describedby", () => {
it("associates tooltip with the trigger via aria-describedby", () => {
render(
<Tooltip text="Associated tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
const describedBy = btn.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
expect(document.getElementById(tooltipId)).toBeTruthy();
});
});

View File

@ -1,436 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for canvas keyboard shortcuts (useKeyboardShortcuts hook).
*
* Covers: Esc, Enter/Shift+Enter, Cmd+]/[, Z, and Arrow keys.
*
* The hook is tested by dispatching KeyboardEvents at the window and
* asserting the resulting store mutations / dispatched events.
*/
import React from "react";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardShortcuts } from "../useKeyboardShortcuts";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockSavePosition = vi.fn().mockResolvedValue(undefined);
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{
getState: () => mockStoreState,
}
),
}));
// Module-level mutable state so tests can mutate between cases
const mockStoreState = {
selectedNodeId: null as string | null,
selectedNodeIds: new Set<string>(),
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: { parentId?: string | null };
width?: number;
height?: number;
}>,
contextMenu: null as { x: number; y: number; nodeId: string } | null,
closeContextMenu: vi.fn(),
selectNode: vi.fn(),
clearSelection: vi.fn(),
bumpZOrder: vi.fn(),
savePosition: mockSavePosition,
moveNode: vi.fn(),
onNodesChange: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
// Reset to default empty state between tests
mockStoreState.selectedNodeId = null;
mockStoreState.selectedNodeIds = new Set();
mockStoreState.nodes = [];
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.clearSelection.mockClear();
mockStoreState.bumpZOrder.mockClear();
mockStoreState.moveNode.mockClear();
mockStoreState.savePosition.mockClear();
mockStoreState.onNodesChange.mockClear();
});
// ─── Test wrapper ────────────────────────────────────────────────────────────
function ShortcutTestComponent() {
useKeyboardShortcuts();
return <div data-testid="canvas-root" />;
}
function renderWithProvider() {
return render(<ShortcutTestComponent />);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Esc — deselect / close context menu", () => {
it("closes the context menu when one is open", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalledTimes(1);
});
it("clears the batch selection when no context menu is open", () => {
mockStoreState.contextMenu = null;
mockStoreState.selectedNodeIds = new Set(["n1", "n2"]);
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.clearSelection).toHaveBeenCalledTimes(1);
});
it("deselects the focused node when no batch selection exists", () => {
mockStoreState.contextMenu = null;
mockStoreState.selectedNodeIds = new Set();
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
});
});
describe("Enter — hierarchy navigation", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
{ id: "n3", position: { x: 200, y: 0 }, data: { parentId: null } },
];
});
it("navigates to the first child on Enter", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2");
});
it("navigates to the parent on Shift+Enter", () => {
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
mockStoreState.selectedNodeId = "n2";
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter", shiftKey: true });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
});
it("does NOT navigate when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
});
});
describe("Cmd+]/[ — z-order bump", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
});
it("bumps z-order forward on Cmd+]", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "]", metaKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
});
it("bumps z-order backward on Cmd+[", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "[", metaKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", -1);
});
it("uses Ctrl as the modifier key", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
});
});
describe("Z — zoom-to-team", () => {
let dispatchedEvents: CustomEvent[] = [];
beforeEach(() => {
dispatchedEvents = [];
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
window.addEventListener("molecule:zoom-to-team", (e) => {
dispatchedEvents.push(e as CustomEvent);
});
});
afterEach(() => {
window.removeEventListener("molecule:zoom-to-team", () => {});
});
it("dispatches zoom-to-team when the selected node has children", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(1);
expect(dispatchedEvents[0].detail.nodeId).toBe("n1");
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
});
it("does NOT fire when the node has no children", () => {
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
];
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
});
it("skips when the target element is an input", () => {
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
document.body.removeChild(input);
});
});
describe("Arrow keys — keyboard node movement", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 100, y: 200 }, data: { parentId: null } },
];
});
it("moves the selected node down on ArrowDown", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, 10);
});
it("moves the selected node up on ArrowUp", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowUp" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, -10);
});
it("moves the selected node right on ArrowRight", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 10, 0);
});
it("moves the selected node left on ArrowLeft", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", -10, 0);
});
it("moves 50 px when Shift is held", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown", shiftKey: true });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, 50);
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
});
it("skips when the target element is an input", () => {
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(input);
});
it("skips when a modal dialog is already open", () => {
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
// NOTE: "prevents default browser scroll on arrow keys" was removed.
// jsdom's KeyboardEvent.initKeyboardEvent does not copy the preventDefault
// function from eventProperties into the real KeyboardEvent, so a
// preventDefault mock passed via fireEvent.keyDown(eventProperties) is
// never called. The guard (selected node required) is covered by
// "does NOT fire when no node is selected". The e.preventDefault() call
// itself is verified by code inspection.
});
describe("all shortcuts respect inInput guard", () => {
it("ArrowDown is skipped in an input element", () => {
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
fireEvent.keyDown(textarea, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(textarea);
});
it("Enter navigation is skipped in an input element", () => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
document.body.removeChild(input);
});
});
describe("Cmd/Ctrl+Arrow — keyboard node resize", () => {
beforeEach(() => {
mockStoreState.nodes = [
{
id: "n1",
position: { x: 0, y: 0 },
data: { parentId: null },
width: 210,
height: 110,
},
];
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
});
it("resizes height down (smaller) on Cmd/Ctrl+ArrowUp", () => {
// Node starts at minHeight=110 (no children). Shrinking clamps to min —
// height stays 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes height up (larger) on Cmd/Ctrl+ArrowDown", () => {
fireEvent.keyDown(window, { key: "ArrowDown", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 120 },
}),
]);
});
it("resizes width down (smaller) on Cmd/Ctrl+ArrowLeft", () => {
// Node starts at minWidth=210 (no children). Shrinking clamps to min —
// width stays 210. Height is unchanged.
fireEvent.keyDown(window, { key: "ArrowLeft", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes width up (larger) on Cmd/Ctrl+ArrowRight", () => {
fireEvent.keyDown(window, { key: "ArrowRight", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 220, height: 110 },
}),
]);
});
it("uses 2px step with Shift held", () => {
// Step is 2px with Shift, but minHeight=110 clamps the result.
// 110 - 2 = 108, Math.max(110, 108) = 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true, shiftKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
dimensions: { width: 210, height: 110 },
}),
]);
});
it("respects min-height constraint (no children)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
// After shrinking from 110 to 100, another ArrowUp hits min-height of 110
// (110 - 10 = 100, but 100 < 110 so it should stay at 110)
// Actually: 110 -> 100 -> 110 (resets to min)
// Let me check: the hook does Math.max(minHeight, currentHeight - step)
// minHeight=110, step=10, so 110 - 10 = 100, but Math.max(110, 100) = 110
// So two ArrowUp calls should both result in height=100 then height=110?
// Wait: 110 - 10 = 100, Math.max(110, 100) = 110 (not 100)
// So the height never goes below 110. After first: 110 -> 100, but clamped to 110.
// Actually Math.max(110, 100) = 110, so the height never changes.
// The min constraint is respected — height stays at 110.
expect(mockStoreState.onNodesChange).toHaveBeenLastCalledWith([
expect.objectContaining({ dimensions: { width: 210, height: 110 } }),
]);
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips when a modal dialog is open", () => {
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
it("skips plain arrow keys (no modifier) — moveNode is called instead", () => {
fireEvent.keyDown(window, { key: "ArrowUp" });
expect(mockStoreState.moveNode).toHaveBeenCalled();
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips Alt+Arrow (not a resize combo)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", altKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
});
});

View File

@ -2,13 +2,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { type NodeChange, type Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
/** Returns true if the node has any direct child in the node list. */
function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean {
return nodes.some((n) => n.data.parentId === nodeId);
}
/** /**
* Canvas-wide keyboard shortcuts. All bound to the document window so * Canvas-wide keyboard shortcuts. All bound to the document window so
@ -21,9 +14,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
* Cmd/Ctrl+] bump selected node forward in z-order * Cmd/Ctrl+] bump selected node forward in z-order
* Cmd/Ctrl+[ bump selected node backward in z-order * Cmd/Ctrl+[ bump selected node backward in z-order
* Z zoom-to-team if the selected node has children * Z zoom-to-team if the selected node has children
* Arrow keys move selected node 10px (50px with Shift)
* Cmd/Ctrl+Arrow resize selected node ( height, width)
* Cmd/Ctrl+Shift+Arrow resize by 2px per press (fine control)
*/ */
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
@ -90,76 +80,6 @@ export function useKeyboardShortcuts() {
); );
} }
} }
// Arrow-key node movement — Figma-style keyboard drag for keyboard users.
// 10 px per press, 50 px with Shift held. Only fires when a node
// is selected and the target isn't a form control. Skipped when a
// modifier key (Cmd/Ctrl/Alt) is held so those combos can be used
// for other shortcuts (e.g. Cmd+Arrow = resize).
if (
!inInput &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
(e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight")
) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
// Skip when a modal/dialog is already open — dialogs own their own
// arrow-key semantics and shouldn't trigger canvas moves.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
const step = e.shiftKey ? 50 : 10;
let dx = 0;
let dy = 0;
if (e.key === "ArrowUp") dy = -step;
else if (e.key === "ArrowDown") dy = step;
else if (e.key === "ArrowLeft") dx = -step;
else dx = step;
state.moveNode(selectedId, dx, dy);
}
// Cmd/Ctrl+Arrow — keyboard-accessible node resize.
// ↑/↓ resizes height, ←/→ resizes width.
// 10 px per press (2 px with Shift for fine control).
// Uses the same onNodesChange('dimensions') path that NodeResizer uses.
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
(e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight")
) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
const step = e.shiftKey ? 2 : 10;
const node = state.nodes.find((n) => n.id === selectedId);
if (!node) return;
const currentWidth = (node.width ?? 210) as number;
const currentHeight = (node.height ?? 110) as number;
const minWidth = hasChildren(node.id, state.nodes) ? 360 : 210;
const minHeight = hasChildren(node.id, state.nodes) ? 200 : 110;
let newWidth = currentWidth;
let newHeight = currentHeight;
if (e.key === "ArrowUp") newHeight = Math.max(minHeight, currentHeight - step);
else if (e.key === "ArrowDown") newHeight = currentHeight + step;
else if (e.key === "ArrowLeft") newWidth = Math.max(minWidth, currentWidth - step);
else newWidth = currentWidth + step;
const change: NodeChange = {
type: "dimensions",
id: selectedId,
dimensions: { width: newWidth, height: newHeight },
};
state.onNodesChange([change]);
}
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);

View File

@ -109,7 +109,7 @@ export function OrgTokensTab() {
Organization API Keys Organization API Keys
</h3> </h3>
</div> </div>
<p className="text-[10px] text-ink-mid leading-relaxed"> <p className="text-[10px] text-ink-soft leading-relaxed">
Full-admin bearer tokens for this organization. Use with external Full-admin bearer tokens for this organization. Use with external
integrations, CLI tools, or AI agents that need to manage integrations, CLI tools, or AI agents that need to manage
workspaces, settings, and secrets. Each key has the same workspaces, settings, and secrets. Each key has the same
@ -182,13 +182,13 @@ export function OrgTokensTab() {
{/* Token list */} {/* Token list */}
{loading ? ( {loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading keys... <Spinner /> Loading keys...
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<div className="text-center py-6"> <div className="text-center py-6">
<p className="text-xs text-ink-mid">No active keys</p> <p className="text-xs text-ink-soft">No active keys</p>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Create a key above to authenticate API calls to this organization. Create a key above to authenticate API calls to this organization.
</p> </p>
</div> </div>
@ -209,7 +209,7 @@ export function OrgTokensTab() {
{t.name} {t.name}
</span> </span>
)} )}
<div className="text-[9px] text-ink-mid space-x-3"> <div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span> <span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && ( {t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span> <span>Last used {formatAge(t.last_used_at)}</span>

View File

@ -81,7 +81,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold text-ink">API Tokens</h3> <h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-mid mt-0.5"> <p className="text-[10px] text-ink-soft mt-0.5">
Bearer tokens for authenticating API calls to this workspace. Bearer tokens for authenticating API calls to this workspace.
</p> </p>
</div> </div>
@ -129,13 +129,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{/* Token list */} {/* Token list */}
{loading ? ( {loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading tokens... <Spinner /> Loading tokens...
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<div className="text-center py-6"> <div className="text-center py-6">
<p className="text-xs text-ink-mid">No active tokens</p> <p className="text-xs text-ink-soft">No active tokens</p>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Create a token to authenticate API calls. Create a token to authenticate API calls.
</p> </p>
</div> </div>
@ -150,7 +150,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded"> <code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
{t.prefix}... {t.prefix}...
</code> </code>
<div className="text-[9px] text-ink-mid space-x-3"> <div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span> <span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && ( {t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span> <span>Last used {formatAge(t.last_used_at)}</span>

View File

@ -142,7 +142,7 @@ export function ActivityTab({ workspaceId }: Props) {
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${ className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
filter === f.id filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600" ? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60" : "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`} }`}
> >
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label} <span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
@ -153,7 +153,7 @@ export function ActivityTab({ workspaceId }: Props) {
onClick={() => setAutoRefresh(!autoRefresh)} onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh} aria-pressed={autoRefresh}
className={`text-[11px] px-1.5 py-0.5 rounded ${ className={`text-[11px] px-1.5 py-0.5 rounded ${
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid" autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
}`} }`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"} title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
> >
@ -177,7 +177,7 @@ export function ActivityTab({ workspaceId }: Props) {
</button> </button>
</div> </div>
</div> </div>
<div className="mt-1.5 text-[10px] text-ink-mid"> <div className="mt-1.5 text-[10px] text-ink-soft">
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"} {activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
</div> </div>
</div> </div>
@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
{/* Activity list */} {/* Activity list */}
<div className="flex-1 overflow-y-auto p-3 space-y-1.5"> <div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{loading && activities.length === 0 && ( {loading && activities.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div> <div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
)} )}
{error && ( {error && (
@ -196,8 +196,8 @@ export function ActivityTab({ workspaceId }: Props) {
{!loading && !error && activities.length === 0 && ( {!loading && !error && activities.length === 0 && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-ink-mid text-xs">No activity recorded yet</div> <div className="text-ink-soft text-xs">No activity recorded yet</div>
<div className="text-ink-mid text-[9px] mt-1"> <div className="text-ink-soft text-[9px] mt-1">
Activity logs appear when agents communicate or perform tasks Activity logs appear when agents communicate or perform tasks
</div> </div>
</div> </div>
@ -265,16 +265,16 @@ function ActivityRow({
</span> </span>
{entry.duration_ms != null && ( {entry.duration_ms != null && (
<span className="text-[8px] text-ink-mid font-mono tabular-nums shrink-0"> <span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
{entry.duration_ms}ms {entry.duration_ms}ms
</span> </span>
)} )}
<span className="text-[8px] text-ink-mid shrink-0"> <span className="text-[8px] text-ink-soft shrink-0">
{formatTime(entry.created_at)} {formatTime(entry.created_at)}
</span> </span>
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
{expanded ? "▼" : "▶"} {expanded ? "▼" : "▶"}
</span> </span>
</div> </div>
@ -296,7 +296,7 @@ function ActivityRow({
{resolveName(entry.source_id)} {resolveName(entry.source_id)}
</span> </span>
)} )}
<span className="text-[9px] text-ink-mid"></span> <span className="text-[9px] text-ink-soft"></span>
{entry.target_id && ( {entry.target_id && (
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}> <span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
{resolveName(entry.target_id)} {resolveName(entry.target_id)}
@ -338,7 +338,7 @@ function ActivityRow({
{entry.response_body && ( {entry.response_body && (
<JsonBlock label="Response" data={entry.response_body} /> <JsonBlock label="Response" data={entry.response_body} />
)} )}
<div className="text-[8px] text-ink-mid font-mono select-all"> <div className="text-[8px] text-ink-soft font-mono select-all">
ID: {entry.id} ID: {entry.id}
</div> </div>
</div> </div>
@ -386,7 +386,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
} }
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words"> <div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)} {text.slice(0, 2000)}
</div> </div>
@ -429,7 +429,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words"> <div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)} {text.slice(0, 2000)}
</div> </div>
@ -440,7 +440,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) { function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
return ( return (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-[8px] text-ink-mid uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span> <span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}> <span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
{value} {value}
</span> </span>
@ -451,7 +451,7 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) { function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono"> <pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
{JSON.stringify(data, null, 2)} {JSON.stringify(data, null, 2)}
</pre> </pre>

View File

@ -158,7 +158,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Usage stats */} {/* Usage stats */}
{loading ? ( {loading ? (
<p className="text-xs text-ink-mid" data-testid="budget-loading"> <p className="text-xs text-ink-soft" data-testid="budget-loading">
Loading Loading
</p> </p>
) : fetchError ? ( ) : fetchError ? (
@ -172,7 +172,7 @@ export function BudgetSection({ workspaceId }: Props) {
<span className="text-xs text-ink-mid">Credits used</span> <span className="text-xs text-ink-mid">Credits used</span>
<span className="text-xs font-mono text-ink-mid"> <span className="text-xs font-mono text-ink-mid">
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span> <span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
<span className="text-ink-mid mx-1">/</span> <span className="text-ink-soft mx-1">/</span>
<span data-testid="budget-limit-value"> <span data-testid="budget-limit-value">
{budget.budget_limit != null {budget.budget_limit != null
? budget.budget_limit.toLocaleString() ? budget.budget_limit.toLocaleString()
@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Remaining credits */} {/* Remaining credits */}
{budget.budget_remaining != null && ( {budget.budget_remaining != null && (
<p className="text-[11px] text-ink-mid" data-testid="budget-remaining"> <p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
{budget.budget_remaining.toLocaleString()} credits remaining {budget.budget_remaining.toLocaleString()} credits remaining
</p> </p>
)} )}
@ -227,7 +227,7 @@ export function BudgetSection({ workspaceId }: Props) {
data-testid="budget-limit-input" data-testid="budget-limit-input"
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors" className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/> />
<p className="text-xs text-ink-mid">Leave blank for unlimited</p> <p className="text-xs text-ink-soft">Leave blank for unlimited</p>
{saveError && ( {saveError && (
<div <div

View File

@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) { if (loading) {
return ( return (
<div className="p-4 text-ink-mid text-xs">Loading channels...</div> <div className="p-4 text-ink-soft text-xs">Loading channels...</div>
); );
} }
@ -271,7 +271,7 @@ export function ChannelsTab({ workspaceId }: Props) {
{showForm && ( {showForm && (
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50"> <div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
<div> <div>
<label htmlFor={platformId} className="text-[10px] text-ink-mid block mb-1">Platform</label> <label htmlFor={platformId} className="text-[10px] text-ink-soft block mb-1">Platform</label>
<select <select
id={platformId} id={platformId}
value={formType} value={formType}
@ -327,7 +327,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className="rounded border-line" className="rounded border-line"
/> />
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span> <span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-ink-mid ml-auto">{chat.type} {chat.chat_id}</span> <span className="text-[10px] text-ink-soft ml-auto">{chat.type} {chat.chat_id}</span>
</label> </label>
))} ))}
<button <button
@ -347,8 +347,8 @@ export function ChannelsTab({ workspaceId }: Props) {
)} )}
<div> <div>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
Allowed Users <span className="text-ink-mid">(optional, comma-separated)</span> Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
</label> </label>
<input <input
id={allowedUsersId} id={allowedUsersId}
@ -357,7 +357,7 @@ export function ChannelsTab({ workspaceId }: Props) {
placeholder="123456789, 987654321" placeholder="123456789, 987654321"
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600" className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
/> />
<p className="text-[11px] text-ink-mid mt-0.5"> <p className="text-[11px] text-ink-soft mt-0.5">
Platform-specific user IDs. Leave empty to allow everyone. Platform-specific user IDs. Leave empty to allow everyone.
</p> </p>
</div> </div>
@ -380,8 +380,8 @@ export function ChannelsTab({ workspaceId }: Props) {
{/* Channel list */} {/* Channel list */}
{channels.length === 0 && !showForm && ( {channels.length === 0 && !showForm && (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-ink-mid text-xs">No channels connected</p> <p className="text-ink-soft text-xs">No channels connected</p>
<p className="text-ink-mid text-[10px] mt-1"> <p className="text-ink-soft text-[10px] mt-1">
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms. Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
</p> </p>
</div> </div>
@ -402,7 +402,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<span className="text-xs font-medium text-ink"> <span className="text-xs font-medium text-ink">
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)} {ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span> </span>
<span className="text-[10px] text-ink-mid"> <span className="text-[10px] text-ink-soft">
{ch.config.chat_id || ch.config.channel_id || ""} {ch.config.chat_id || ch.config.channel_id || ""}
</span> </span>
</div> </div>
@ -419,7 +419,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className={`text-[10px] px-2 py-0.5 rounded transition ${ className={`text-[10px] px-2 py-0.5 rounded transition ${
ch.enabled ch.enabled
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50" ? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid" : "bg-surface-card/50 text-ink-soft hover:text-ink-mid"
}`} }`}
> >
{ch.enabled ? "On" : "Off"} {ch.enabled ? "On" : "Off"}
@ -432,7 +432,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-[10px] text-ink-mid"> <div className="flex items-center gap-4 text-[10px] text-ink-soft">
<span>{ch.message_count} messages</span> <span>{ch.message_count} messages</span>
<span>Last: {relativeTime(ch.last_message_at)}</span> <span>Last: {relativeTime(ch.last_message_at)}</span>
{ch.allowed_users.length > 0 && ( {ch.allowed_users.length > 0 && (
@ -474,9 +474,9 @@ function SchemaField({
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"; "w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
return ( return (
<div> <div>
<label htmlFor={inputId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={inputId} className="text-[10px] text-ink-soft block mb-1">
{field.label} {field.label}
{!field.required && <span className="text-ink-mid"> (optional)</span>} {!field.required && <span className="text-ink-soft"> (optional)</span>}
</label> </label>
{field.type === "textarea" ? ( {field.type === "textarea" ? (
<textarea <textarea
@ -499,7 +499,7 @@ function SchemaField({
)} )}
{renderExtras?.()} {renderExtras?.()}
{field.help && ( {field.help && (
<p className="text-[11px] text-ink-mid mt-0.5">{field.help}</p> <p className="text-[11px] text-ink-soft mt-0.5">{field.help}</p>
)} )}
</div> </div>
); );

View File

@ -965,7 +965,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{/* Messages */} {/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3"> <div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && ( {loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div> <div className="text-xs text-ink-soft text-center py-4">Loading chat history...</div>
)} )}
{!loading && loadError !== null && messages.length === 0 && ( {!loading && loadError !== null && messages.length === 0 && (
<div <div
@ -984,7 +984,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div> </div>
)} )}
{!loading && loadError === null && messages.length === 0 && ( {!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
No messages yet. Send a message to start chatting with this agent. No messages yet. Send a message to start chatting with this agent.
</div> </div>
)} )}
@ -1002,7 +1002,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
scroll resting against the top of the conversation IS the scroll resting against the top of the conversation IS the
signal. */} signal. */}
{hasMore && messages.length > 0 && ( {hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1"> <div ref={topRef} className="text-xs text-ink-soft text-center py-1">
{loadingOlder ? "Loading older messages…" : " "} {loadingOlder ? "Loading older messages…" : " "}
</div> </div>
)} )}
@ -1153,7 +1153,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{thinkingElapsed}s {thinkingElapsed}s
</div> </div>
{activityLog.length > 0 && ( {activityLog.length > 0 && (
<div className="mt-1.5 text-[9px] text-ink-mid space-y-0.5"> <div className="mt-1.5 text-[9px] text-ink-soft space-y-0.5">
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div> <div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
{activityLog.map((line, i) => ( {activityLog.map((line, i) => (
<div key={line + i} className="pl-2 border-l border-line"> {line}</div> <div key={line + i} className="pl-2 border-l border-line"> {line}</div>

View File

@ -97,7 +97,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
{JSON.stringify(card, null, 2)} {JSON.stringify(card, null, 2)}
</pre> </pre>
) : ( ) : (
<div className="text-[10px] text-ink-mid">No agent card</div> <div className="text-[10px] text-ink-soft">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-good">Updated</div>} {success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
<button type="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); }}
@ -635,16 +635,16 @@ export function ConfigTab({ workspaceId }: Props) {
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty; const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
if (loading) { if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading config...</div>; return <div className="p-4 text-xs text-ink-soft">Loading config...</div>;
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Mode toggle */} {/* Mode toggle */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30"> <div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
<span className="text-[10px] text-ink-mid">config.yaml</span> <span className="text-[10px] text-ink-soft">config.yaml</span>
<label className="flex items-center gap-1.5 cursor-pointer"> <label className="flex items-center gap-1.5 cursor-pointer">
<span className="text-[9px] text-ink-mid">Raw YAML</span> <span className="text-[9px] text-ink-soft">Raw YAML</span>
<input <input
type="checkbox" type="checkbox"
checked={rawMode} checked={rawMode}
@ -677,7 +677,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General"> <Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} /> <TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<div> <div>
<label htmlFor={descriptionId} className="text-[10px] text-ink-mid block mb-1">Description</label> <label htmlFor={descriptionId} className="text-[10px] text-ink-soft block mb-1">Description</label>
<textarea <textarea
id={descriptionId} id={descriptionId}
value={config.description} value={config.description}
@ -689,7 +689,7 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono /> <TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<div> <div>
<label htmlFor={tierId} className="text-[10px] text-ink-mid block mb-1">Tier</label> <label htmlFor={tierId} className="text-[10px] text-ink-soft block mb-1">Tier</label>
<select <select
id={tierId} id={tierId}
value={config.tier} value={config.tier}
@ -707,7 +707,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Runtime"> <Section title="Runtime">
<div> <div>
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label> <label htmlFor={runtimeId} className="text-[10px] text-ink-soft block mb-1">Runtime</label>
<select <select
id={runtimeId} id={runtimeId}
value={config.runtime || ""} value={config.runtime || ""}
@ -791,7 +791,7 @@ export function ConfigTab({ workspaceId }: Props) {
// workspace_secrets MODEL_PROVIDER override. // workspace_secrets MODEL_PROVIDER override.
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-[10px] text-ink-mid block mb-1">Model</label> <label className="text-[10px] text-ink-soft block mb-1">Model</label>
<input <input
type="text" type="text"
value={currentModelId} value={currentModelId}
@ -808,9 +808,9 @@ export function ConfigTab({ workspaceId }: Props) {
/> />
</div> </div>
<div> <div>
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-soft block mb-1">
Provider Provider
<span className="ml-1 text-ink-mid"> <span className="ml-1 text-ink-soft">
(override leave empty to auto-derive from model slug) (override leave empty to auto-derive from model slug)
</span> </span>
</label> </label>
@ -859,7 +859,7 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value" placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
/> />
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
This declares which env var <em>names</em> the workspace needs. This declares which env var <em>names</em> the workspace needs.
Set the actual values in the <strong>Secrets</strong> section Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at below those are encrypted and mounted into the container at
@ -867,7 +867,7 @@ export function ConfigTab({ workspaceId }: Props) {
</p> </p>
{currentModelSpec?.required_env?.length && {currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && ( !arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-ink-mid mt-1 flex items-center gap-2"> <div className="text-[10px] text-ink-soft mt-1 flex items-center gap-2">
<span> <span>
Template suggests{" "} Template suggests{" "}
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "} <code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
@ -890,9 +890,9 @@ export function ConfigTab({ workspaceId }: Props) {
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && ( (config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
<Section title="Claude Settings" defaultOpen={false}> <Section title="Claude Settings" defaultOpen={false}>
<div> <div>
<label htmlFor={effortId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={effortId} className="text-[10px] text-ink-soft block mb-1">
Effort Effort
<span className="ml-1 text-ink-mid">(output_config.effort Opus 4.7+)</span> <span className="ml-1 text-ink-soft">(output_config.effort Opus 4.7+)</span>
</label> </label>
<select <select
id={effortId} id={effortId}
@ -910,9 +910,9 @@ export function ConfigTab({ workspaceId }: Props) {
</select> </select>
</div> </div>
<div> <div>
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={taskBudgetId} className="text-[10px] text-ink-soft block mb-1">
Task Budget (tokens) Task Budget (tokens)
<span className="ml-1 text-ink-mid">(output_config.task_budget.total 0 = unset)</span> <span className="ml-1 text-ink-soft">(output_config.task_budget.total 0 = unset)</span>
</label> </label>
<input <input
id={taskBudgetId} id={taskBudgetId}
@ -938,7 +938,7 @@ export function ConfigTab({ workspaceId }: Props) {
showing the misnamed list-input affordance. */} showing the misnamed list-input affordance. */}
<Section title="Prompt Files" defaultOpen={false}> <Section title="Prompt Files" defaultOpen={false}>
<p className="text-[10px] text-ink-mid px-1 pb-1"> <p className="text-[10px] text-ink-soft px-1 pb-1">
Markdown files that compose this workspace&apos;s system prompt. Markdown files that compose this workspace&apos;s system prompt.
Loaded in order at boot from the workspace config dir Loaded in order at boot from the workspace config dir
(e.g. <code className="font-mono">system-prompt.md</code>,{' '} (e.g. <code className="font-mono">system-prompt.md</code>,{' '}
@ -966,7 +966,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}> <Section title="Sandbox" defaultOpen={false}>
<div> <div>
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-mid block mb-1">Backend</label> <label htmlFor={sandboxBackendId} className="text-[10px] text-ink-soft block mb-1">Backend</label>
<select <select
id={sandboxBackendId} id={sandboxBackendId}
value={config.sandbox?.backend || "docker"} value={config.sandbox?.backend || "docker"}

View File

@ -242,7 +242,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{data.lastSampleError} {data.lastSampleError}
</pre> </pre>
) : ( ) : (
<p className="text-xs text-ink-mid">No error detail recorded.</p> <p className="text-xs text-ink-soft">No error detail recorded.</p>
)} )}
<button <button
type="button" type="button"
@ -268,7 +268,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<div key={s.id} className="flex items-start gap-2"> <div key={s.id} className="flex items-start gap-2">
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span> <span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
{s.description && ( {s.description && (
<span className="text-xs text-ink-mid">{s.description}</span> <span className="text-xs text-ink-soft">{s.description}</span>
)} )}
</div> </div>
))} ))}
@ -281,11 +281,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
{peersError ? ( {peersError ? (
<p className="text-xs text-bad">{peersError}</p> <p className="text-xs text-bad">{peersError}</p>
) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? ( ) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? (
<p className="text-xs text-ink-mid"> <p className="text-xs text-ink-soft">
Peers are only discoverable while the workspace is online. Peers are only discoverable while the workspace is online.
</p> </p>
) : peers.length === 0 ? ( ) : peers.length === 0 ? (
<p className="text-xs text-ink-mid">No reachable peers</p> <p className="text-xs text-ink-soft">No reachable peers</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{peers.map((p) => ( {peers.map((p) => (
@ -297,7 +297,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
> >
<StatusDot status={p.status} /> <StatusDot status={p.status} />
<span className="text-xs text-ink">{p.name}</span> <span className="text-xs text-ink">{p.name}</span>
{p.role && <span className="text-[10px] text-ink-mid">{p.role}</span>} {p.role && <span className="text-[10px] text-ink-soft">{p.role}</span>}
</button> </button>
))} ))}
</div> </div>
@ -385,7 +385,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
const fieldId = useId(); const fieldId = useId();
return ( return (
<div> <div>
<label htmlFor={fieldId} className="text-[10px] text-ink-mid block mb-0.5">{label}</label> <label htmlFor={fieldId} className="text-[10px] text-ink-soft block mb-0.5">{label}</label>
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })} {cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
</div> </div>
); );
@ -394,7 +394,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-xs text-ink-mid">{label}</span> <span className="text-xs text-ink-soft">{label}</span>
<span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}> <span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
{value} {value}
</span> </span>

View File

@ -62,7 +62,7 @@ export function EventsTab({ workspaceId }: Props) {
}, [loadEvents]); }, [loadEvents]);
if (loading && events.length === 0) { if (loading && events.length === 0) {
return <div className="p-4 text-xs text-ink-mid">Loading events...</div>; return <div className="p-4 text-xs text-ink-soft">Loading events...</div>;
} }
return ( return (
@ -88,7 +88,7 @@ export function EventsTab({ workspaceId }: Props) {
)} )}
{!error && events.length === 0 ? ( {!error && events.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No events yet</p> <p className="text-xs text-ink-soft text-center py-4">No events yet</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{events.map((event) => { {events.map((event) => {
@ -115,10 +115,10 @@ export function EventsTab({ workspaceId }: Props) {
> >
{event.event_type} {event.event_type}
</span> </span>
<span className="text-[9px] text-ink-mid ml-auto"> <span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)} {formatTime(event.created_at)}
</span> </span>
<span aria-hidden="true" className="text-[10px] text-ink-mid"> <span aria-hidden="true" className="text-[10px] text-ink-soft">
{isOpen ? "▼" : "▶"} {isOpen ? "▼" : "▶"}
</span> </span>
</button> </button>
@ -128,7 +128,7 @@ export function EventsTab({ workspaceId }: Props) {
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40"> <pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(event.payload, null, 2)} {JSON.stringify(event.payload, null, 2)}
</pre> </pre>
<div className="mt-1 text-[9px] text-ink-mid font-mono"> <div className="mt-1 text-[9px] text-ink-soft font-mono">
ID: {event.id} ID: {event.id}
</div> </div>
</div> </div>

View File

@ -77,7 +77,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
return ( return (
<div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded"> <div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded">
<h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3> <h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3>
<p className="text-[10px] text-ink-mid mb-2"> <p className="text-[10px] text-ink-soft mb-2">
This workspace runs an external agent. Use these controls to This workspace runs an external agent. Use these controls to
re-show the setup snippets or rotate the workspace token. re-show the setup snippets or rotate the workspace token.
</p> </p>

View File

@ -203,7 +203,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
}; };
if (loading) { if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading files...</div>; return <div className="p-4 text-xs text-ink-soft">Loading files...</div>;
} }
return ( return (
@ -304,7 +304,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
)} )}
{files.length === 0 ? ( {files.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-ink-mid text-center"> <div className="px-3 py-4 text-[10px] text-ink-soft text-center">
{rootDragHover {rootDragHover
? "Drop to upload to root" ? "Drop to upload to root"
: root === "/configs" : root === "/configs"

View File

@ -36,7 +36,7 @@ export function FileEditor({
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="text-2xl opacity-20 mb-2">📄</div> <div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-ink-mid">Select a file to edit</p> <p className="text-[10px] text-ink-soft">Select a file to edit</p>
</div> </div>
</div> </div>
); );
@ -56,7 +56,7 @@ export function FileEditor({
<button <button
onClick={onDownload} onClick={onDownload}
aria-label="Download file" aria-label="Download file"
className="text-[10px] text-ink-mid hover:text-ink-mid" className="text-[10px] text-ink-soft hover:text-ink-mid"
> >
</button> </button>
@ -74,7 +74,7 @@ export function FileEditor({
{/* Editor area */} {/* Editor area */}
{loadingFile ? ( {loadingFile ? (
<div className="p-4 text-xs text-ink-mid">Loading...</div> <div className="p-4 text-xs text-ink-soft">Loading...</div>
) : ( ) : (
<textarea <textarea
ref={editorRef} ref={editorRef}

View File

@ -209,7 +209,7 @@ function TreeItem({
onContextMenu={(e) => openContextMenu(e, node)} onContextMenu={(e) => openContextMenu(e, node)}
{...dragProps} {...dragProps}
> >
<span className="text-[9px] text-ink-mid w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span> <span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span> <span className="text-[10px]">📁</span>
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span> <span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
<button <button

View File

@ -132,7 +132,7 @@ export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors" : "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
} }
> >
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-mid">{item.icon}</span>} {item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
{item.label} {item.label}
</button> </button>
))} ))}

View File

@ -39,7 +39,7 @@ export function FilesToolbar({
<option value="/workspace">/workspace</option> <option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option> <option value="/plugins">/plugins</option>
</select> </select>
<span className="text-[10px] text-ink-mid">{fileCount} files</span> <span className="text-[10px] text-ink-soft">{fileCount} files</span>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{root === "/configs" && ( {root === "/configs" && (
@ -62,7 +62,7 @@ export function FilesToolbar({
</button> </button>
</> </>
)} )}
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Download all files"> <button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Download all files">
Export Export
</button> </button>
{root === "/configs" && ( {root === "/configs" && (
@ -70,7 +70,7 @@ export function FilesToolbar({
Clear Clear
</button> </button>
)} )}
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Refresh"> <button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Refresh">
</button> </button>
</div> </div>

View File

@ -27,7 +27,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72" viewBox="0 0 72 72"
fill="none" fill="none"
aria-hidden="true" aria-hidden="true"
className="text-ink-mid mb-4" className="text-ink-soft mb-4"
> >
{/* Folder body */} {/* Folder body */}
<path <path
@ -47,7 +47,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
/> />
</svg> </svg>
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3> <h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
This workspace runs the{" "} This workspace runs the{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime, <span className="font-mono text-ink-mid">{runtime}</span> runtime,
whose filesystem isn't owned by the platform. Use the Chat tab to whose filesystem isn't owned by the platform. Use the Chat tab to

View File

@ -182,7 +182,7 @@ export function MemoryTab({ workspaceId }: Props) {
}; };
if (loading) { if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>; return <div className="p-4 text-xs text-ink-soft">Loading memory...</div>;
} }
return ( return (
@ -197,7 +197,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-xs font-medium text-ink">Awareness dashboard</div> <div className="text-xs font-medium text-ink">Awareness dashboard</div>
<p className="text-[10px] text-ink-mid"> <p className="text-[10px] text-ink-soft">
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering. Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
</p> </p>
</div> </div>
@ -230,7 +230,7 @@ export function MemoryTab({ workspaceId }: Props) {
/> />
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid"> <div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-soft">
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here. Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
</div> </div>
) )
@ -238,7 +238,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3"> <div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-ink">Awareness dashboard is collapsed</p> <p className="text-xs text-ink">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-ink-mid truncate"> <p className="text-[10px] text-ink-soft truncate">
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>. Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p> </p>
</div> </div>
@ -254,15 +254,15 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3"> <div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span> <span className="uppercase tracking-[0.18em] text-ink-soft">Status</span>
<span className="font-medium text-good">Connected</span> <span className="font-medium text-good">Connected</span>
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span> <span className="uppercase tracking-[0.18em] text-ink-soft">Mode</span>
<span className="font-medium text-ink">{awarenessStatus}</span> <span className="font-medium text-ink">{awarenessStatus}</span>
</div> </div>
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="flex items-center justify-between gap-2 min-w-0">
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span> <span className="uppercase tracking-[0.18em] text-ink-soft">Workspace</span>
<span className="font-mono text-ink-mid truncate">{workspaceId}</span> <span className="font-mono text-ink-mid truncate">{workspaceId}</span>
</div> </div>
</div> </div>
@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-xs font-medium text-ink">Workspace KV memory</div> <div className="text-xs font-medium text-ink">Workspace KV memory</div>
<p className="text-[10px] text-ink-mid"> <p className="text-[10px] text-ink-soft">
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>. Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p> </p>
</div> </div>
@ -350,7 +350,7 @@ export function MemoryTab({ workspaceId }: Props) {
{showAdvanced ? ( {showAdvanced ? (
entries.length === 0 ? ( entries.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p> <p className="text-xs text-ink-soft text-center py-4">No memory entries</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{entries.map((entry) => ( {entries.map((entry) => (
@ -364,11 +364,11 @@ export function MemoryTab({ workspaceId }: Props) {
<span className="text-xs font-mono text-accent">{entry.key}</span> <span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{entry.expires_at && ( {entry.expires_at && (
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
TTL {new Date(entry.expires_at).toLocaleString()} TTL {new Date(entry.expires_at).toLocaleString()}
</span> </span>
)} )}
<span className="text-[10px] text-ink-mid"> <span className="text-[10px] text-ink-soft">
{expanded === entry.key ? "▼" : "▶"} {expanded === entry.key ? "▼" : "▶"}
</span> </span>
</div> </div>
@ -420,7 +420,7 @@ export function MemoryTab({ workspaceId }: Props) {
</pre> </pre>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
Updated: {new Date(entry.updated_at).toLocaleString()} Updated: {new Date(entry.updated_at).toLocaleString()}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -452,7 +452,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3"> <div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-ink">Advanced workspace memory is hidden</p> <p className="text-xs text-ink">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-ink-mid truncate"> <p className="text-[10px] text-ink-soft truncate">
KV entries remain available if you need the raw platform store. KV entries remain available if you need the raw platform store.
</p> </p>
</div> </div>

View File

@ -180,7 +180,7 @@ export function ScheduleTab({ workspaceId }: Props) {
}; };
if (loading) { if (loading) {
return <div className="p-4 text-[10px] text-ink-mid">Loading schedules...</div>; return <div className="p-4 text-[10px] text-ink-soft">Loading schedules...</div>;
} }
return ( return (
@ -207,11 +207,11 @@ export function ScheduleTab({ workspaceId }: Props) {
placeholder="Schedule name (e.g., Daily security scan)" placeholder="Schedule name (e.g., Daily security scan)"
value={formName} value={formName}
onChange={(e) => setFormName(e.target.value)} onChange={(e) => setFormName(e.target.value)}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid" className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1"> <div className="flex-1">
<label htmlFor={cronId} className="text-[10px] text-ink-mid block mb-0.5">Cron Expression</label> <label htmlFor={cronId} className="text-[10px] text-ink-soft block mb-0.5">Cron Expression</label>
<input <input
id={cronId} id={cronId}
type="text" type="text"
@ -219,12 +219,12 @@ export function ScheduleTab({ workspaceId }: Props) {
onChange={(e) => setFormCron(e.target.value)} onChange={(e) => setFormCron(e.target.value)}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono" className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
/> />
<div className="text-[10px] text-ink-mid mt-0.5"> <div className="text-[10px] text-ink-soft mt-0.5">
{cronToHuman(formCron)} {cronToHuman(formCron)}
</div> </div>
</div> </div>
<div className="w-24"> <div className="w-24">
<label htmlFor={timezoneId} className="text-[10px] text-ink-mid block mb-0.5">Timezone</label> <label htmlFor={timezoneId} className="text-[10px] text-ink-soft block mb-0.5">Timezone</label>
<select <select
id={timezoneId} id={timezoneId}
value={formTimezone} value={formTimezone}
@ -245,14 +245,14 @@ export function ScheduleTab({ workspaceId }: Props) {
</div> </div>
</div> </div>
<div> <div>
<label htmlFor={promptId} className="text-[10px] text-ink-mid block mb-0.5">Prompt / Task</label> <label htmlFor={promptId} className="text-[10px] text-ink-soft block mb-0.5">Prompt / Task</label>
<textarea <textarea
id={promptId} id={promptId}
value={formPrompt} value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)} onChange={(e) => setFormPrompt(e.target.value)}
placeholder="What should the agent do on this schedule?" placeholder="What should the agent do on this schedule?"
rows={3} rows={3}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid resize-y" className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft resize-y"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -290,7 +290,7 @@ export function ScheduleTab({ workspaceId }: Props) {
Cancel Cancel
</button> </button>
</div> </div>
<div className="text-[10px] text-ink-mid space-y-0.5"> <div className="text-[10px] text-ink-soft space-y-0.5">
<div>Common patterns:</div> <div>Common patterns:</div>
<div className="font-mono">{"0 9 * * *"} Daily at 9:00 AM</div> <div className="font-mono">{"0 9 * * *"} Daily at 9:00 AM</div>
<div className="font-mono">{"*/30 * * * *"} Every 30 minutes</div> <div className="font-mono">{"*/30 * * * *"} Every 30 minutes</div>
@ -306,7 +306,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<div className="p-6 text-center"> <div className="p-6 text-center">
<div className="text-2xl mb-2"></div> <div className="text-2xl mb-2"></div>
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div> <div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
<div className="text-[9px] text-ink-mid"> <div className="text-[9px] text-ink-soft">
Add a schedule to run tasks automatically daily scans, periodic reports, standup reminders. Add a schedule to run tasks automatically daily scans, periodic reports, standup reminders.
</div> </div>
</div> </div>
@ -336,16 +336,16 @@ export function ScheduleTab({ workspaceId }: Props) {
{sched.name || "Unnamed schedule"} {sched.name || "Unnamed schedule"}
</span> </span>
</div> </div>
<div className="text-[9px] text-ink-mid mt-0.5 font-mono"> <div className="text-[9px] text-ink-soft mt-0.5 font-mono">
{cronToHuman(sched.cron_expr)} {cronToHuman(sched.cron_expr)}
{sched.timezone !== "UTC" && ( {sched.timezone !== "UTC" && (
<span className="text-ink-mid"> ({sched.timezone})</span> <span className="text-ink-soft"> ({sched.timezone})</span>
)} )}
</div> </div>
<div className="text-[9px] text-ink-mid mt-0.5 truncate"> <div className="text-[9px] text-ink-soft mt-0.5 truncate">
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""} {sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
</div> </div>
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-mid"> <div className="flex items-center gap-3 mt-1 text-[8px] text-ink-soft">
<span>Last: {relativeTime(sched.last_run_at)}</span> <span>Last: {relativeTime(sched.last_run_at)}</span>
<span>Next: {relativeTime(sched.next_run_at)}</span> <span>Next: {relativeTime(sched.next_run_at)}</span>
<span>Runs: {sched.run_count}</span> <span>Runs: {sched.run_count}</span>

View File

@ -320,7 +320,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
aria-label="Plugins (none installed)" aria-label="Plugins (none installed)"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Plugins</span> <span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
<span className="text-[11px] text-ink-mid">0 installed</span> <span className="text-[11px] text-ink-mid">0 installed</span>
</div> </div>
<button <button
@ -342,7 +342,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3"> <div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Plugins</div> <div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
<h3 className="mt-1 text-sm font-semibold text-ink"> <h3 className="mt-1 text-sm font-semibold text-ink">
{installed.length} installed {installed.length} installed
</h3> </h3>
@ -379,21 +379,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-ink">{p.name}</span> <span className="text-[11px] font-medium text-ink">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>} {p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
{inert && ( {inert && (
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm"> <span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm">
inert on this runtime inert on this runtime
</span> </span>
)} )}
</div> </div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>} {p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.skills && p.skills.length > 0 && ( {p.skills && p.skills.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{p.skills.slice(0, 4).map((s) => ( {p.skills.slice(0, 4).map((s) => (
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span> <span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span>
))} ))}
{p.skills.length > 4 && ( {p.skills.length > 4 && (
<span className="text-[10px] text-ink-mid">+{p.skills.length - 4}</span> <span className="text-[10px] text-ink-soft">+{p.skills.length - 4}</span>
)} )}
</div> </div>
)} )}
@ -417,7 +417,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{/* Install from any source (github://, clawhub://, …) */} {/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5"> <div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5"> <div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid"> <div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">
Install from source Install from source
</div> </div>
{sourceSchemes.length > 0 && ( {sourceSchemes.length > 0 && (
@ -425,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{sourceSchemes.map((s) => ( {sourceSchemes.map((s) => (
<span <span
key={s} key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid" className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-soft"
> >
{s}:// {s}://
</span> </span>
@ -444,7 +444,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
}} }}
placeholder="e.g. github://owner/repo#v1.0" placeholder="e.g. github://owner/repo#v1.0"
spellCheck={false} spellCheck={false}
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-mid focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50" className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
/> />
<button <button
onClick={handleInstallCustom} onClick={handleInstallCustom}
@ -454,12 +454,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
{installing === customSource.trim() ? "Installing..." : "Install"} {installing === customSource.trim() ? "Installing..." : "Install"}
</button> </button>
</div> </div>
<div className="mt-1 text-[10px] text-ink-mid"> <div className="mt-1 text-[10px] text-ink-soft">
Local registry plugins below; paste any scheme URL above for GitHub or other sources. Local registry plugins below; paste any scheme URL above for GitHub or other sources.
</div> </div>
</div> </div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div> <div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Available plugins</div>
{/* Retry visible whenever registry is empty including {/* Retry visible whenever registry is empty including
the loading state so a stuck fetch (Fast Refresh the loading state so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a stranded promise, slow server, browser quirk) has a
@ -486,21 +486,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
)} )}
</div> </div>
{registryLoading && registry.length === 0 ? ( {registryLoading && registry.length === 0 ? (
<div className="text-[10px] text-ink-mid">Loading registry</div> <div className="text-[10px] text-ink-soft">Loading registry</div>
) : registryError ? ( ) : registryError ? (
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5"> <div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5"> <div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry Couldn't load the plugin registry
</div> </div>
<div className="text-[10px] text-bad/80">{registryError}</div> <div className="text-[10px] text-bad/80">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid"> <div className="mt-1 text-[10px] text-ink-soft">
Check the platform server is reachable at /plugins. The Retry button is in the header above. Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div> </div>
</div> </div>
) : registry.length === 0 ? ( ) : registry.length === 0 ? (
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5"> <div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div> <div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid"> <div className="text-[10px] text-ink-soft">
This usually means the platform's plugins/ directory is empty. This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos. Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div> </div>
@ -514,13 +514,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] text-ink-mid">{p.name}</span> <span className="text-[11px] text-ink-mid">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>} {p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
</div> </div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>} {p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.tags && p.tags.length > 0 && ( {p.tags && p.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{p.tags.map((t) => ( {p.tags.map((t) => (
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-mid">{t}</span> <span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-soft">{t}</span>
))} ))}
</div> </div>
)} )}
@ -556,7 +556,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3"> <div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Workspace skills</div> <div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Workspace skills</div>
<h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3> <h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -564,7 +564,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<MetaPill label="Runtime" value={capability.runtime || "unknown"} /> <MetaPill label="Runtime" value={capability.runtime || "unknown"} />
</div> </div>
</div> </div>
<p className="mt-2 text-[11px] leading-5 text-ink-mid"> <p className="mt-2 text-[11px] leading-5 text-ink-soft">
Live skill directory from the Agent Card updates when the workspace hot-reloads skills. Live skill directory from the Agent Card updates when the workspace hot-reloads skills.
</p> </p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@ -593,7 +593,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skills.length === 0 ? ( {skills.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center"> <div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center">
<div className="text-sm text-ink">No skills loaded</div> <div className="text-sm text-ink">No skills loaded</div>
<p className="mt-2 text-[11px] leading-5 text-ink-mid"> <p className="mt-2 text-[11px] leading-5 text-ink-soft">
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them. Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
</p> </p>
</div> </div>
@ -604,7 +604,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-xs font-semibold text-ink">{skill.name}</div> <div className="text-xs font-semibold text-ink">{skill.name}</div>
<div className="mt-0.5 text-[10px] font-mono text-ink-mid">{skill.id}</div> <div className="mt-0.5 text-[10px] font-mono text-ink-soft">{skill.id}</div>
</div> </div>
{skill.tags.length > 0 && ( {skill.tags.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5"> <div className="flex flex-wrap justify-end gap-1.5">
@ -626,7 +626,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skill.examples.length > 0 && ( {skill.examples.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-mid">Examples</div> <div className="text-[9px] uppercase tracking-[0.2em] text-ink-soft">Examples</div>
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
{skill.examples.slice(0, 2).map((example, index) => ( {skill.examples.slice(0, 2).map((example, index) => (
<div <div
@ -666,7 +666,7 @@ function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[]
function MetaPill({ label, value }: { label: string; value: string }) { function MetaPill({ label, value }: { label: string; value: string }) {
return ( return (
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid"> <span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-mid">{label}</span> <span className="uppercase tracking-[0.18em] text-[8px] text-ink-soft">{label}</span>
<span className="font-medium">{value}</span> <span className="font-medium">{value}</span>
</span> </span>
); );

View File

@ -37,7 +37,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72" viewBox="0 0 72 72"
fill="none" fill="none"
aria-hidden="true" aria-hidden="true"
className="text-ink-mid mb-4" className="text-ink-soft mb-4"
> >
<rect <rect
x="10" x="10"
@ -74,7 +74,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
/> />
</svg> </svg>
<h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3> <h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3>
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
This workspace runs the{" "} This workspace runs the{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime, <span className="font-mono text-ink-mid">{runtime}</span> runtime,
which doesn't expose a shell. Use the Chat tab to interact with the which doesn't expose a shell. Use the Chat tab to interact with the

View File

@ -48,7 +48,7 @@ export function TracesTab({ workspaceId }: Props) {
}, [loadTraces]); }, [loadTraces]);
if (loading) { if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading traces...</div>; return <div className="p-4 text-xs text-ink-soft">Loading traces...</div>;
} }
return ( return (
@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) {
onClick={loadTraces} onClick={loadTraces}
// Added focus-visible ring; previous version was hover-only, // Added focus-visible ring; previous version was hover-only,
// invisible to keyboard users. // invisible to keyboard users.
className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" className="text-[10px] text-ink-soft hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
> >
Refresh Refresh
</button> </button>
@ -75,9 +75,9 @@ export function TracesTab({ workspaceId }: Props) {
{traces.length === 0 && !error ? ( {traces.length === 0 && !error ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div> <div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
<p className="text-xs text-ink-mid">No traces yet</p> <p className="text-xs text-ink-soft">No traces yet</p>
<details className="mt-2 text-[10px] text-ink-mid"> <details className="mt-2 text-[10px] text-ink-soft">
<summary className="cursor-pointer text-ink-mid hover:text-ink-mid">How to enable tracing</summary> <summary className="cursor-pointer text-ink-soft hover:text-ink-mid">How to enable tracing</summary>
<p className="mt-1"> <p className="mt-1">
Set <code className="font-mono text-ink-mid">LANGFUSE_HOST</code>, <code className="font-mono text-ink-mid">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-ink-mid">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing. Set <code className="font-mono text-ink-mid">LANGFUSE_HOST</code>, <code className="font-mono text-ink-mid">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-ink-mid">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing.
</p> </p>
@ -108,20 +108,20 @@ export function TracesTab({ workspaceId }: Props) {
}`} /> }`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div> <div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
<div className="text-[9px] text-ink-mid">{formatTime(trace.timestamp)}</div> <div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{trace.latency != null && ( {trace.latency != null && (
<span className="text-[9px] text-ink-mid tabular-nums"> <span className="text-[9px] text-ink-soft tabular-nums">
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`} {trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
</span> </span>
)} )}
{trace.usage?.total != null && ( {trace.usage?.total != null && (
<span className="text-[9px] text-ink-mid tabular-nums"> <span className="text-[9px] text-ink-soft tabular-nums">
{trace.usage.total} tok {trace.usage.total} tok
</span> </span>
)} )}
<span aria-hidden="true" className="text-[9px] text-ink-mid"> <span aria-hidden="true" className="text-[9px] text-ink-soft">
{isOpen ? "▼" : "▶"} {isOpen ? "▼" : "▶"}
</span> </span>
</div> </div>
@ -131,7 +131,7 @@ export function TracesTab({ workspaceId }: Props) {
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30"> <div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
{trace.input && ( {trace.input && (
<div> <div>
<div className="text-[9px] text-ink-mid uppercase tracking-wider mt-2 mb-1">Input</div> <div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32"> <pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))} {String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
</pre> </pre>
@ -139,18 +139,18 @@ export function TracesTab({ workspaceId }: Props) {
)} )}
{trace.output && ( {trace.output && (
<div> <div>
<div className="text-[9px] text-ink-mid uppercase tracking-wider mb-1">Output</div> <div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32"> <pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))} {String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
</pre> </pre>
</div> </div>
)} )}
{trace.totalCost != null && ( {trace.totalCost != null && (
<div className="text-[9px] text-ink-mid"> <div className="text-[9px] text-ink-soft">
Cost: ${trace.totalCost.toFixed(6)} Cost: ${trace.totalCost.toFixed(6)}
</div> </div>
)} )}
<div className="text-[8px] text-ink-mid font-mono select-all"> <div className="text-[8px] text-ink-soft font-mono select-all">
{trace.id} {trace.id}
</div> </div>
</div> </div>

View File

@ -389,7 +389,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
}, [messages]); }, [messages]);
if (loading) { if (loading) {
return <div className="text-xs text-ink-mid text-center py-8">Loading agent communications...</div>; return <div className="text-xs text-ink-soft text-center py-8">Loading agent communications...</div>;
} }
if (loadError !== null && messages.length === 0) { if (loadError !== null && messages.length === 0) {
@ -415,10 +415,10 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
No agent-to-agent communications yet. No agent-to-agent communications yet.
<br /> <br />
<span className="text-ink-mid">Delegations and peer messages will appear here.</span> <span className="text-ink-soft">Delegations and peer messages will appear here.</span>
</div> </div>
); );
} }
@ -513,20 +513,7 @@ function GroupedCommsView({
/> />
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
{visible.map((msg) => {visible.map((msg) =>
// Only render the error UI when there is NO usable response msg.status === "error" ? (
// content. A "error" status from the platform means the HTTP
// transport layer had a problem — but the agent response text
// may have arrived and been stored in response_body.text.
// Delegation results set responseText via extractResponseText
// once that function learned to parse body.text, so checking
// !msg.responseText here correctly identifies "no actual reply
// was received" vs. "reply arrived but status=error".
//
// Without this guard, successful delegation results were
// rendered as error banners, PMs saw "restart" prompts and
// restarted working agents, and retry storms formed as the
// platform re-delivered the same completed work (issue #159).
msg.status === "error" && !msg.responseText ? (
<ErrorMessage key={msg.id} msg={msg} /> <ErrorMessage key={msg.id} msg={msg} />
) : ( ) : (
<NormalMessage key={msg.id} msg={msg} /> <NormalMessage key={msg.id} msg={msg} />
@ -613,10 +600,10 @@ function PeerTabButton({
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${ className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
active active
? "border-b-2 border-cyan-500 text-cyan-200" ? "border-b-2 border-cyan-500 text-cyan-200"
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid" : "border-b-2 border-transparent text-ink-soft hover:text-ink-mid"
}`} }`}
> >
{label} <span className="text-[9px] text-ink-mid">({count})</span> {label} <span className="text-[9px] text-ink-soft">({count})</span>
</button> </button>
); );
} }
@ -669,7 +656,7 @@ function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
role="status" role="status"
aria-label={`Waiting for reply from ${m.peerName}`} aria-label={`Waiting for reply from ${m.peerName}`}
> >
<div className="text-[9px] text-ink-mid mb-1"> To {m.peerName}</div> <div className="text-[9px] text-ink-soft mb-1"> To {m.peerName}</div>
<span className="flex items-center gap-2 text-ink-mid"> <span className="flex items-center gap-2 text-ink-mid">
<span className="flex gap-0.5" aria-hidden="true"> <span className="flex gap-0.5" aria-hidden="true">
<span <span
@ -708,7 +695,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
: "bg-surface-card/80 text-ink border border-line/30" : "bg-surface-card/80 text-ink border border-line/30"
}`} }`}
> >
<div className="text-[9px] text-ink-mid mb-1"> <div className="text-[9px] text-ink-soft mb-1">
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`} {msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
</div> </div>
{msg.text ? ( {msg.text ? (
@ -731,7 +718,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
{msg.responseText} {msg.responseText}
</MarkdownBody> </MarkdownBody>
)} )}
<div className="text-[9px] text-ink-mid mt-1"> <div className="text-[9px] text-ink-soft mt-1">
{new Date(msg.timestamp).toLocaleTimeString()} {new Date(msg.timestamp).toLocaleTimeString()}
</div> </div>
</div> </div>
@ -804,7 +791,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
</div> </div>
{msg.text && ( {msg.text && (
<div className="text-[10px] text-ink-mid mb-1.5"> <div className="text-[10px] text-ink-soft mb-1.5">
<span className="uppercase tracking-wide">Task</span> <span className="uppercase tracking-wide">Task</span>
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody> <MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
</div> </div>
@ -841,7 +828,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
</div> </div>
)} )}
<div className="text-[9px] text-ink-mid mt-1.5"> <div className="text-[9px] text-ink-soft mt-1.5">
{new Date(msg.timestamp).toLocaleTimeString()} {new Date(msg.timestamp).toLocaleTimeString()}
</div> </div>
</div> </div>

View File

@ -9,7 +9,6 @@
// AttachmentLightbox). // AttachmentLightbox).
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads"; import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews"; import { AttachmentChip } from "./AttachmentViews";
@ -44,8 +43,13 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
void (async () => { void (async () => {
try { try {
const href = resolveAttachmentHref(workspaceId, attachment.uri); const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, { const res = await fetch(href, {
headers: platformAuthHeaders(), headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(60_000), signal: AbortSignal.timeout(60_000),
}); });
@ -112,5 +116,9 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
); );
} }
// Local getTenantSlug() removed — auth-header construction now goes function getTenantSlug(): string | null {
// through platformAuthHeaders() from @/lib/api (#178). if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -35,7 +35,6 @@
// downscale via canvas, but defer that to v2. // downscale via canvas, but defer that to v2.
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads"; import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox"; import { AttachmentLightbox } from "./AttachmentLightbox";
@ -76,14 +75,22 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
} }
// Platform-auth path: identical to downloadChatFile but we keep // Platform-auth path: identical to downloadChatFile but we keep
// the blob (don't trigger a Save-As). Auth headers come from the // the blob (don't trigger a Save-As). Use the same headers it does
// shared `platformAuthHeaders()` helper — one source of truth for // by going through it indirectly — no, downloadChatFile triggers a
// every authenticated raw fetch in the canvas (#178). // Save-As. Need a separate fetch.
void (async () => { void (async () => {
try { try {
const href = resolveAttachmentHref(workspaceId, attachment.uri); const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
// Read the same env var downloadChatFile reads — single source
// of truth would be cleaner; refactor opportunity for PR-2 if
// we add the same path to AttachmentVideo.
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, { const res = await fetch(href, {
headers: platformAuthHeaders(), headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
@ -177,7 +184,15 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
); );
} }
// Local getTenantSlug() removed — auth-header construction now goes // Internal helper — duplicated from uploads.ts (it's not exported
// through platformAuthHeaders() from @/lib/api which uses the canonical // there). Kept local so this component doesn't reach into private
// getTenantSlug() from @/lib/tenant. This eliminates the duplicate // surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
// hostname-regex + the duplicate bearer-token-attach pattern (#178). // it, lift to an exported helper at that point (the third-caller
// rule).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
// Tenant subdomain shape: <slug>.moleculesai.app
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -33,7 +33,6 @@
// timeout, swap to chip. Implemented as a 3-second watchdog. // timeout, swap to chip. Implemented as a 3-second watchdog.
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads"; import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox"; import { AttachmentLightbox } from "./AttachmentLightbox";
@ -70,8 +69,13 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
void (async () => { void (async () => {
try { try {
const href = resolveAttachmentHref(workspaceId, attachment.uri); const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, { const res = await fetch(href, {
headers: platformAuthHeaders(), headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(60_000), signal: AbortSignal.timeout(60_000),
}); });
@ -185,5 +189,9 @@ function PdfGlyph() {
); );
} }
// Local getTenantSlug() removed — auth-header construction now goes function getTenantSlug(): string | null {
// through platformAuthHeaders() from @/lib/api (#178). if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -26,7 +26,6 @@
// to download the full file. // to download the full file.
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads"; import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews"; import { AttachmentChip } from "./AttachmentViews";
@ -58,13 +57,13 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
void (async () => { void (async () => {
try { try {
const href = resolveAttachmentHref(workspaceId, attachment.uri); const href = resolveAttachmentHref(workspaceId, attachment.uri);
// Only attach platform auth headers for in-platform URIs — const headers: Record<string, string> = {};
// off-platform URLs (HTTP/HTTPS attachments) MUST NOT receive if (isPlatformAttachment(attachment.uri)) {
// our bearer token (it would leak the admin token to a third const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
// party). The branch is preserved with the new shared helper. if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const headers: Record<string, string> = isPlatformAttachment(attachment.uri) const slug = getTenantSlug();
? platformAuthHeaders() if (slug) headers["X-Molecule-Org-Slug"] = slug;
: {}; }
const res = await fetch(href, { const res = await fetch(href, {
headers, headers,
credentials: "include", credentials: "include",
@ -148,7 +147,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
<button <button
type="button" type="button"
onClick={() => onDownload(attachment)} onClick={() => onDownload(attachment)}
className="text-ink-mid hover:text-ink" className="text-ink-soft hover:text-ink"
title={`Download ${attachment.name}`} title={`Download ${attachment.name}`}
aria-label={`Download ${attachment.name}`} aria-label={`Download ${attachment.name}`}
> >
@ -183,5 +182,9 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
); );
} }
// Local getTenantSlug() removed — auth-header construction now goes function getTenantSlug(): string | null {
// through platformAuthHeaders() from @/lib/api (#178). if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -25,7 +25,6 @@
// fetch via service worker. v2 if measured-needed. // fetch via service worker. v2 if measured-needed.
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads"; import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews"; import { AttachmentChip } from "./AttachmentViews";
@ -62,8 +61,13 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
void (async () => { void (async () => {
try { try {
const href = resolveAttachmentHref(workspaceId, attachment.uri); const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, { const res = await fetch(href, {
headers: platformAuthHeaders(), headers,
credentials: "include", credentials: "include",
// Videos are larger than images on average; give the request // Videos are larger than images on average; give the request
// more headroom. The server's per-request body cap (50MB) is // more headroom. The server's per-request body cap (50MB) is
@ -143,5 +147,11 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
); );
} }
// Local getTenantSlug() removed — auth-header construction now goes // Internal helper — same shape as AttachmentImage's. Lifted to a
// through platformAuthHeaders() from @/lib/api (#178). // shared util in PR-2.5 if a third caller needs it (PDF, audio).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -29,11 +29,11 @@ export function PendingAttachmentPill({
<div className="flex items-center gap-1.5 rounded-md border border-line/60 bg-surface-card/80 px-2 py-1 text-[10px] text-ink-mid max-w-[200px]"> <div className="flex items-center gap-1.5 rounded-md border border-line/60 bg-surface-card/80 px-2 py-1 text-[10px] text-ink-mid max-w-[200px]">
<FileGlyph className="text-ink-mid shrink-0" /> <FileGlyph className="text-ink-mid shrink-0" />
<span className="truncate" title={file.name}>{file.name}</span> <span className="truncate" title={file.name}>{file.name}</span>
<span className="text-ink-mid shrink-0 tabular-nums">{formatSize(file.size)}</span> <span className="text-ink-soft shrink-0 tabular-nums">{formatSize(file.size)}</span>
<button <button
onClick={onRemove} onClick={onRemove}
aria-label={`Remove ${file.name}`} aria-label={`Remove ${file.name}`}
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0" className="ml-0.5 text-ink-soft hover:text-ink transition-colors shrink-0"
> >
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg width="10" height="10" 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" /> <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />

View File

@ -4,11 +4,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// API mock — tests can override per case via apiGetMock.mockImplementationOnce. // API mock — tests can override per case via apiGetMock.mockImplementationOnce.
const apiGetMock = vi.fn<(url: string) => Promise<unknown>>(); const apiGetMock = vi.fn<(url: string) => Promise<unknown>>();
const apiPostMock = vi.fn<(url: string, body?: unknown) => Promise<unknown>>();
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { api: {
get: (url: string) => apiGetMock(url), get: (url: string) => apiGetMock(url),
post: (url: string, body?: unknown) => apiPostMock(url, body),
}, },
})); }));
@ -18,23 +16,17 @@ vi.mock("@/hooks/useSocketEvent", () => ({
useSocketEvent: () => {}, useSocketEvent: () => {},
})); }));
// Canvas store — peer name resolution + ErrorMessage requires selectNode // Canvas store — peer name resolution.
// (Zustand hook usage). The mock must support BOTH: vi.mock("@/store/canvas", () => ({
// useCanvasStore.getState().nodes (plain object with getState) useCanvasStore: {
// useCanvasStore((s) => s.selectNode) (Zustand hook with selector) getState: () => ({
vi.mock("@/store/canvas", () => {
const state = {
nodes: [ nodes: [
{ id: "ws-self", data: { name: "Self" } }, { id: "ws-self", data: { name: "Self" } },
{ id: "ws-peer", data: { name: "Peer Agent" } }, { id: "ws-peer", data: { name: "Peer Agent" } },
], ],
selectNode: vi.fn(), }),
}; },
const hook = (selector?: (s: typeof state) => unknown) => }));
selector ? selector(state) : state;
hook.getState = () => state;
return { useCanvasStore: hook };
});
// Toaster shim — AgentCommsPanel imports showToast. // Toaster shim — AgentCommsPanel imports showToast.
vi.mock("../../Toaster", () => ({ vi.mock("../../Toaster", () => ({
@ -49,8 +41,6 @@ import { AgentCommsPanel } from "../AgentCommsPanel";
const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>(); const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>();
beforeEach(() => { beforeEach(() => {
apiGetMock.mockReset(); apiGetMock.mockReset();
apiPostMock.mockReset();
apiPostMock.mockResolvedValue({});
scrollSpy.mockReset(); scrollSpy.mockReset();
Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"]; Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"];
}); });
@ -59,81 +49,6 @@ afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
// Regression test: when a delegation succeeds but the platform persisted
// status="error" (transport-layer HTTP failure, not agent failure), the
// canvas had the response text in msg.text but rendered ErrorMessage
// anyway, burying the real content in an "Underlying error" banner and
// prompting PMs to restart working agents (issue #159).
describe("AgentCommsPanel — error rendering guard (issue #159)", () => {
it("renders NormalMessage when status=error but msg.text is present (successful delegation)", async () => {
// Simulate a delegation result where status="error" (HTTP transport
// failed) but response_body.text carries the actual agent response.
// The correct behaviour: show the content as a normal inbound bubble,
// NOT an error banner.
apiGetMock.mockResolvedValueOnce([
{
id: "act-1",
activity_type: "delegation",
method: "delegate_result",
source_id: "ws-self",
target_id: "ws-peer",
summary: "Delegation completed",
request_body: null,
// delegation.go stores response_body as {text: "...", delegation_id: "..."}
response_body: {
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
delegation_id: "delg_01jx8q4n3k",
},
status: "error", // transport-layer error, not agent failure
created_at: "2026-04-25T18:00:00Z",
},
]);
render(<AgentCommsPanel workspaceId="ws-self" />);
// The response text should appear in a normal inbound bubble, NOT in
// an error banner. Specifically: no "Failed to deliver" or "returned
// an error" text should appear.
await waitFor(() => {
expect(screen.queryByText(/failed to deliver/i)).toBeNull();
expect(screen.queryByText(/returned an error/i)).toBeNull();
});
// The actual content must be visible.
await waitFor(() =>
expect(
screen.getByText(/tier-check fails NO REVIEWS/i),
).toBeDefined(),
);
});
it("renders ErrorMessage when status=error and msg.text is absent (true failure)", async () => {
// True delivery failure: no response body, no text. The error banner
// IS appropriate here.
apiGetMock.mockResolvedValueOnce([
{
id: "act-1",
activity_type: "a2a_send",
source_id: "ws-self",
target_id: "ws-peer",
method: "message/send",
summary: "A2A send failed",
request_body: null,
response_body: null,
status: "error",
created_at: "2026-04-25T18:00:00Z",
},
]);
render(<AgentCommsPanel workspaceId="ws-self" />);
// Error banner IS shown for true failures (no content).
// jsdom doesn't reliably match role="alert" in getByRole, so use
// getByText instead.
const errorBanner = await waitFor(() =>
screen.getByText(/failed to deliver/i),
);
expect(errorBanner).toBeDefined();
});
});
describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => { describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => {
it("shows loading text while history fetch is in flight", () => { it("shows loading text while history fetch is in flight", () => {
apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ })); apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));

View File

@ -64,54 +64,6 @@ describe("extractRequestText", () => {
}; };
expect(extractRequestText(body)).toBe(""); expect(extractRequestText(body)).toBe("");
}); });
// Regression: delegation.go stores request_body as {"task": "...", "delegation_id": "..."}.
// extractRequestText was checking only the A2A params.message.parts path, so
// outbound delegation messages were rendered as blank bubbles.
// Fix: check body.task first (delegation format), then fall back to A2A.
it("extracts text from body.task (delegation format)", () => {
const body = {
task: "Deploy the staging environment for this sprint's release",
delegation_id: "delg_01jx8q4n3k",
};
expect(extractRequestText(body)).toBe(
"Deploy the staging environment for this sprint's release"
);
});
it("prefers body.task over A2A params when both present", () => {
const body = {
task: "Delegation text wins",
params: {
message: {
parts: [{ kind: "text", text: "A2A text" }],
},
},
};
// body.task is checked first; delegation wins for delegation activities.
expect(extractRequestText(body)).toBe("Delegation text wins");
});
it("falls back to A2A format when body.task is absent", () => {
const body = {
params: {
message: {
parts: [{ kind: "text", text: "A2A fallback" }],
},
},
};
expect(extractRequestText(body)).toBe("A2A fallback");
});
it("returns empty string when body.task is empty string", () => {
const body = { task: "" };
expect(extractRequestText(body)).toBe("");
});
it("returns empty string when body.task is not a string", () => {
const body = { task: 42 };
expect(extractRequestText(body)).toBe("");
});
}); });
describe("extractResponseText", () => { describe("extractResponseText", () => {
@ -209,43 +161,6 @@ describe("extractResponseText", () => {
}; };
expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two"); expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two");
}); });
// Regression: delegation.go stores response_body as
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
// Without body.text handling, extractResponseText returns "" for
// delegate_result rows, causing the error UI to fire even when the
// delegation succeeded (issue #159).
it("extracts from body.text (delegation response_body shape)", () => {
const body = {
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
delegation_id: "delg_01jx8q4n3k",
};
expect(extractResponseText(body)).toBe(
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
);
});
it("prefers body.result over body.text when both present", () => {
const body = {
result: { parts: [{ kind: "text", text: "A2A result wins" }] },
text: "Delegation text",
};
// result path is checked first; A2A wins when both present.
expect(extractResponseText(body)).toBe("A2A result wins");
});
it("returns empty string when body.text is empty string", () => {
expect(extractResponseText({ text: "" })).toBe("");
});
it("extracts from body.response_preview (DELEGATION_COMPLETE WS event shape)", () => {
const body = {
response_preview: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
};
expect(extractResponseText(body)).toBe(
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
);
});
}); });
describe("extractTextsFromParts", () => { describe("extractTextsFromParts", () => {

View File

@ -114,15 +114,9 @@ function basename(uri: string): string {
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file"; return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file";
} }
/** Extract user message text from an activity log request_body. /** Extract user message text from an activity log request_body */
*
* Delegation activities from delegation.go store the task text directly
* at `body.task` as a plain string: {"task": "...", "delegation_id": "..."}.
* Check this first before falling back to the A2A JSON-RPC format
* (`body.params.message.parts[].text`). */
export function extractRequestText(body: Record<string, unknown> | null): string { export function extractRequestText(body: Record<string, unknown> | null): string {
if (!body) return ""; if (!body) return "";
if (typeof body.task === "string" && body.task) return body.task;
const params = body.params as Record<string, unknown> | undefined; const params = body.params as Record<string, unknown> | undefined;
const msg = params?.message as Record<string, unknown> | undefined; const msg = params?.message as Record<string, unknown> | undefined;
const parts = msg?.parts as Array<Record<string, unknown>> | undefined; const parts = msg?.parts as Array<Record<string, unknown>> | undefined;
@ -168,10 +162,10 @@ export function extractResponseText(body: Record<string, unknown>): string {
if (rootTexts.length > 0) collected.push(rootTexts.join("\n")); if (rootTexts.length > 0) collected.push(rootTexts.join("\n"));
// Task shape: {result: {artifacts: [{parts: [...]}]}} // Task shape: {result: {artifacts: [{parts: [...]}]}}
const artifacts = result.artifacts as Array<Record<string, unknown> | undefined>; const artifacts = result.artifacts as Array<Record<string, unknown>> | undefined;
if (artifacts) { if (artifacts) {
for (const a of artifacts) { for (const a of artifacts) {
const t = extractTextsFromParts(a?.parts); const t = extractTextsFromParts(a.parts);
if (t) collected.push(t); if (t) collected.push(t);
} }
} }
@ -179,20 +173,6 @@ export function extractResponseText(body: Record<string, unknown>): string {
if (collected.length > 0) return collected.join("\n"); if (collected.length > 0) return collected.join("\n");
} }
// Delegation results from delegation.go store response_body as
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
// Check this after the body.result path so A2A responses take
// precedence when both shapes are somehow present.
// Without this, responseText is always "" for delegate_result rows,
// causing the error UI to fire even when the delegation succeeded
// (issue #159).
if (typeof body.text === "string" && body.text) return body.text;
// DELEGATION_COMPLETE event (via canvas-events WS handler) stores
// response_body as {response_preview: "..."}. Handle this too.
if (typeof body.response_preview === "string" && body.response_preview) {
return body.response_preview;
}
// {task: "text"} — request body format, shouldn't be in response but handle it // {task: "text"} — request body format, shouldn't be in response but handle it
if (typeof body.task === "string") return body.task; if (typeof body.task === "string") return body.task;
} catch { /* ignore */ } } catch { /* ignore */ }

View File

@ -1,16 +1,12 @@
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api"; import { PLATFORM_URL } from "@/lib/api";
import { getTenantSlug } from "@/lib/tenant";
import type { ChatAttachment } from "./types"; import type { ChatAttachment } from "./types";
/** Chat attachments are intentionally uploaded via a direct fetch() /** Chat attachments are intentionally uploaded via a direct fetch()
* instead of the `api.post` helper `api.post` JSON-stringifies the * instead of the `api.post` helper `api.post` JSON-stringifies the
* body, which would 500 on a Blob. Auth headers (tenant slug, admin * body, which would 500 on a Blob. Mirrors the header plumbing
* token, credentials) come from `platformAuthHeaders()` the same * (tenant slug, admin token, credentials) so SaaS + self-hosted
* helper `request()` uses, so a missing bearer surfaces as a single * callers work the same way. */
* fix site instead of N copies. We deliberately do NOT set
* Content-Type so the browser writes the multipart boundary into the
* header; setting it manually would yield a multipart body the server
* can't parse. See lib/api.ts platformAuthHeaders() for the full
* rationale on why this pair must stay matched. */
export async function uploadChatFiles( export async function uploadChatFiles(
workspaceId: string, workspaceId: string,
files: File[], files: File[],
@ -20,12 +16,18 @@ export async function uploadChatFiles(
const form = new FormData(); const form = new FormData();
for (const f of files) form.append("files", f, f.name); for (const f of files) form.append("files", f, f.name);
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
// Uploads legitimately take a while on cold cache (tar write + // Uploads legitimately take a while on cold cache (tar write +
// docker cp into the container). 60s is comfortable for the 25MB/ // docker cp into the container). 60s is comfortable for the 25MB/
// 50MB caps the server enforces. // 50MB caps the server enforces.
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, { const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
method: "POST", method: "POST",
headers: platformAuthHeaders(), headers,
body: form, body: form,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(60_000), signal: AbortSignal.timeout(60_000),
@ -141,8 +143,14 @@ export async function downloadChatFile(
return; return;
} }
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(href, { const res = await fetch(href, {
headers: platformAuthHeaders(), headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(60_000), signal: AbortSignal.timeout(60_000),
}); });

View File

@ -50,7 +50,7 @@ export function TextInput({ label, value, onChange, placeholder, mono }: { label
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`; const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
return ( return (
<div> <div>
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label> <label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
<input <input
id={id} id={id}
type="text" type="text"
@ -68,7 +68,7 @@ export function NumberInput({ label, value, onChange, min, max }: { label: strin
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`; const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
return ( return (
<div> <div>
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label> <label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
<input <input
id={id} id={id}
type="number" type="number"
@ -97,12 +97,12 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
const [input, setInput] = useState(""); const [input, setInput] = useState("");
return ( return (
<div> <div>
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label> <label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
<div className="flex flex-wrap gap-1 mb-1"> <div className="flex flex-wrap gap-1 mb-1">
{values.map((v, i) => ( {values.map((v, i) => (
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-card border border-line rounded text-[10px] text-ink-mid font-mono"> <span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-card border border-line rounded text-[10px] text-ink-mid font-mono">
{v} {v}
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-mid hover:text-bad">×</button> <button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-soft hover:text-bad">×</button>
</span> </span>
))} ))}
</div> </div>

View File

@ -101,9 +101,9 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] text-ink-mid">{label}</div> <div className="text-[10px] text-ink-mid">{label}</div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-mono text-ink-mid">{secretKey}</span> <span className="text-[9px] font-mono text-ink-soft">{secretKey}</span>
{isSet && ( {isSet && (
<span className="text-[9px] font-mono text-ink-mid tracking-widest" title="Value is set (encrypted)"> <span className="text-[9px] font-mono text-ink-soft tracking-widest" title="Value is set (encrypted)">
</span> </span>
)} )}
@ -159,7 +159,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
<span className={`text-[10px] font-mono ${globalMode ? "text-warm" : scope === "global" ? "text-ink-mid" : "text-accent"}`}> <span className={`text-[10px] font-mono ${globalMode ? "text-warm" : scope === "global" ? "text-ink-mid" : "text-accent"}`}>
{secretKey} {secretKey}
</span> </span>
<span className="text-[9px] font-mono text-ink-mid tracking-widest ml-2"></span> <span className="text-[9px] font-mono text-ink-soft tracking-widest ml-2"></span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-good">Set</span> <span className="text-[10px] text-good">Set</span>
@ -288,7 +288,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
return ( return (
<Section title="Secrets & API Keys" defaultOpen={false}> <Section title="Secrets & API Keys" defaultOpen={false}>
{loading ? ( {loading ? (
<div className="text-[10px] text-ink-mid">Loading secrets...</div> <div className="text-[10px] text-ink-soft">Loading secrets...</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>} {error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
@ -369,7 +369,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
</button> </button>
)} )}
<div className="text-[9px] text-ink-mid pt-1"> <div className="text-[9px] text-ink-soft pt-1">
Values are encrypted and never exposed to the browser. Values are encrypted and never exposed to the browser.
{globalMode {globalMode
? " Global keys are shared across all workspaces. Restart workspaces to apply changes." ? " Global keys are shared across all workspaces. Restart workspaces to apply changes."

View File

@ -1,130 +0,0 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Tests for the boot-time matched-pair guard added to next.config.ts.
//
// Why this lives in src/lib/__tests__ even though the function is in
// canvas/next.config.ts:
// - next.config.ts runs as ESM-but-also-CJS depending on which
// consumer loads it (Next.js dev server vs Next.js build); we
// want the test to be a plain ESM module Vitest already handles.
// - Importing from "../../../next.config" pulls in the rest of the
// file (loadMonorepoEnv, the default export, etc.) which has
// side effects on module load (it runs loadMonorepoEnv()
// immediately). To keep the test hermetic we don't import — we
// duplicate the function under test.
//
// Sourcing the function from a shared module would be cleaner, but
// next.config.ts is required to be a single self-contained file by
// Next.js's loader on some host configurations. Pin invariant: the
// duplicated function below MUST stay byte-identical to the one in
// next.config.ts. If you change one, change the other and bump this
// comment.
function checkAdminTokenPair(): void {
const serverSet = !!process.env.ADMIN_TOKEN;
const clientSet = !!process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (serverSet === clientSet) return;
if (serverSet && !clientSet) {
// eslint-disable-next-line no-console
console.error(
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
"canvas will 401 against workspace-server because the bearer header " +
"is never attached. Set both to the same value, or unset both.",
);
} else {
// eslint-disable-next-line no-console
console.error(
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
"workspace-server will reject the bearer because no AdminAuth gate " +
"is configured. Set both to the same value, or unset both.",
);
}
}
describe("checkAdminTokenPair", () => {
// Snapshot env so individual tests can stomp on it without leaking.
// Rebuild from snapshot in afterEach so the next test sees a known
// baseline regardless of mutation pattern.
let originalEnv: Record<string, string | undefined>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
originalEnv = {
ADMIN_TOKEN: process.env.ADMIN_TOKEN,
NEXT_PUBLIC_ADMIN_TOKEN: process.env.NEXT_PUBLIC_ADMIN_TOKEN,
};
delete process.env.ADMIN_TOKEN;
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
if (originalEnv.ADMIN_TOKEN === undefined) delete process.env.ADMIN_TOKEN;
else process.env.ADMIN_TOKEN = originalEnv.ADMIN_TOKEN;
if (originalEnv.NEXT_PUBLIC_ADMIN_TOKEN === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = originalEnv.NEXT_PUBLIC_ADMIN_TOKEN;
errorSpy.mockRestore();
});
it("emits no warning when both are unset", () => {
checkAdminTokenPair();
expect(errorSpy).not.toHaveBeenCalled();
});
it("emits no warning when both are set (matched pair, the happy path)", () => {
process.env.ADMIN_TOKEN = "local-dev-admin";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
checkAdminTokenPair();
expect(errorSpy).not.toHaveBeenCalled();
});
it("warns when ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not", () => {
process.env.ADMIN_TOKEN = "local-dev-admin";
checkAdminTokenPair();
expect(errorSpy).toHaveBeenCalledTimes(1);
// Exact-string assertion — substring would also pass when the
// function's branch logic is broken (e.g. emits both messages, or
// emits the wrong one). Pin the exact message that operators will
// see in their dev console so regressions are visible.
expect(errorSpy).toHaveBeenCalledWith(
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
"canvas will 401 against workspace-server because the bearer header " +
"is never attached. Set both to the same value, or unset both.",
);
});
it("warns when NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
checkAdminTokenPair();
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
"workspace-server will reject the bearer because no AdminAuth gate " +
"is configured. Set both to the same value, or unset both.",
);
});
// Empty string in process.env is the JS-side representation of `KEY=`
// (no value) in a .env file. Treating "" as unset makes the pair
// invariant symmetric: `KEY=` and `unset KEY` produce the same
// verdict. Without this branch, an operator who comments out the
// value but leaves the line would get a false-positive warning.
it("treats empty string as unset (so KEY= and unset KEY are equivalent)", () => {
process.env.ADMIN_TOKEN = "";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
checkAdminTokenPair();
expect(errorSpy).not.toHaveBeenCalled();
});
it("warns when ADMIN_TOKEN is set and NEXT_PUBLIC_ADMIN_TOKEN is empty string", () => {
process.env.ADMIN_TOKEN = "local-dev-admin";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
checkAdminTokenPair();
expect(errorSpy).toHaveBeenCalledTimes(1);
// First branch — server set, client unset.
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not"),
);
});
});

View File

@ -1,97 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Tests for platformAuthHeaders — the shared helper extracted in #178
// to consolidate the bearer-token-attach + tenant-slug-attach pattern
// that was previously duplicated across 7 raw-fetch callsites in the
// canvas (uploads + 5 Attachment* components + the api.ts request()
// function).
//
// What we pin here:
// - Returns a fresh object each call (so callers can mutate without
// leaking into each other).
// - Empty result on a non-tenant host with no admin token (the
// localhost / self-hosted shape).
// - Bearer attached when NEXT_PUBLIC_ADMIN_TOKEN is set.
// - X-Molecule-Org-Slug attached when window.location.hostname is a
// tenant subdomain (<slug>.moleculesai.app).
// - Both attached when both apply (the production SaaS shape).
//
// Why jsdom: getTenantSlug() reads window.location.hostname. Node-only
// environment yields no window and getTenantSlug returns null
// unconditionally — wouldn't exercise the slug branch.
import { platformAuthHeaders } from "../api";
describe("platformAuthHeaders", () => {
let originalAdminToken: string | undefined;
beforeEach(() => {
originalAdminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
});
afterEach(() => {
if (originalAdminToken === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = originalAdminToken;
// jsdom resets hostname between tests via the @vitest-environment
// pragma's per-test isolation. No explicit reset needed.
});
it("returns an empty object on a non-tenant host with no admin token", () => {
// jsdom default hostname is "localhost" — not a tenant slug, so
// getTenantSlug() returns null and no X-Molecule-Org-Slug is added.
const headers = platformAuthHeaders();
expect(headers).toEqual({});
});
it("attaches Authorization when NEXT_PUBLIC_ADMIN_TOKEN is set", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
const headers = platformAuthHeaders();
expect(headers).toEqual({ Authorization: "Bearer local-dev-admin" });
});
it("does NOT attach Authorization when NEXT_PUBLIC_ADMIN_TOKEN is empty string", () => {
// Empty-string env is the JS-side shape of `KEY=` in .env.
// Treating it as unset matches the matched-pair guard in
// next.config.ts (admin-token-pair.test.ts) — symmetric semantics.
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
const headers = platformAuthHeaders();
expect(headers).toEqual({});
});
it("attaches X-Molecule-Org-Slug on a tenant subdomain", () => {
Object.defineProperty(window, "location", {
value: { hostname: "reno-stars.moleculesai.app" },
writable: true,
});
const headers = platformAuthHeaders();
expect(headers).toEqual({ "X-Molecule-Org-Slug": "reno-stars" });
});
it("attaches both when both apply (production SaaS shape)", () => {
Object.defineProperty(window, "location", {
value: { hostname: "reno-stars.moleculesai.app" },
writable: true,
});
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tenant-bearer";
const headers = platformAuthHeaders();
// Pin exact-equality on the full shape — substring/contains
// assertions would also pass for an extra-header bug.
expect(headers).toEqual({
"X-Molecule-Org-Slug": "reno-stars",
Authorization: "Bearer tenant-bearer",
});
});
it("returns a fresh object each call (callers can mutate safely)", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tok";
const a = platformAuthHeaders();
const b = platformAuthHeaders();
expect(a).not.toBe(b); // distinct refs
expect(a).toEqual(b); // same content
a["Content-Type"] = "application/json";
// Mutation on `a` does not leak into `b`.
expect(b["Content-Type"]).toBeUndefined();
});
});

View File

@ -21,45 +21,6 @@ export interface RequestOptions {
timeoutMs?: number; timeoutMs?: number;
} }
/**
* Build the platform auth header set used by every authenticated fetch
* from the canvas. Returns a fresh object so callers can mutate (e.g.
* append `Content-Type` for JSON requests, omit it for FormData).
*
* SaaS cross-origin shape:
* - `X-Molecule-Org-Slug` derived from `window.location.hostname`
* by `getTenantSlug()`. Control plane uses it for fly-replay
* routing. Empty on localhost / non-tenant hosts safe to omit.
* - `Authorization: Bearer <token>` `NEXT_PUBLIC_ADMIN_TOKEN` baked
* into the canvas build (see canvas/Dockerfile L8/L11). Required by
* the workspace-server when `ADMIN_TOKEN` is set on the server side
* (Tier-2b AdminAuth gate, wsauth_middleware.go ~L245). Empty when
* no admin token was provisioned the Tier-1 session-cookie path
* handles that case via `credentials:"include"`.
*
* Why a shared helper: the two-line "read env, attach bearer; read
* slug, attach header" pattern was duplicated across `request()` and
* 7 raw-fetch callsites (chat uploads/download + 5 Attachment*
* components) before this consolidation. A new poller or raw fetch
* that forgets one of the two headers silently 401s against
* workspace-server when ADMIN_TOKEN is set the exact bug shape
* called out in #178 / closes the post-#176 self-review gap.
*
* Callers that want JSON Content-Type should spread this and add it
* themselves; FormData callers should NOT add Content-Type (the
* browser sets the multipart boundary). Centralizing the auth pair
* but leaving Content-Type up to the caller is the minimum viable
* shared shape.
*/
export function platformAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
return headers;
}
async function request<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
@ -67,16 +28,17 @@ async function request<T>(
retryCount = 0, retryCount = 0,
options?: RequestOptions, options?: RequestOptions,
): Promise<T> { ): Promise<T> {
// JSON-bodied request — Content-Type is JSON. Auth pair comes from // SaaS cross-origin shape:
// the shared helper; see its doc comment for the SaaS-shape rationale. // - X-Molecule-Org-Slug: derived from window.location.hostname by
const headers: Record<string, string> = { // getTenantSlug(). Control plane uses it for fly-replay routing.
"Content-Type": "application/json", // Empty on localhost / non-tenant hosts — safe to omit.
...platformAuthHeaders(), // - credentials:"include": sends the session cookie cross-origin.
}; // Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
// Re-read slug locally for the 401 handler below — `headers` already const headers: Record<string, string> = { "Content-Type": "application/json" };
// has it, but the 401 branch needs the bare value to gate the
// session-probe + redirect logic on tenant context.
const slug = getTenantSlug(); const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(`${PLATFORM_URL}${path}`, { const res = await fetch(`${PLATFORM_URL}${path}`, {
method, method,

View File

@ -835,180 +835,3 @@ describe("handleCanvasEvent unknown event", () => {
).not.toThrow(); ).not.toThrow();
}); });
}); });
// ---------------------------------------------------------------------------
// Screen-reader live announcements
// ---------------------------------------------------------------------------
describe("handleCanvasEvent liveAnnouncement", () => {
it("announces WORKSPACE_ONLINE with node name", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Alpha is now online");
});
it("announces WORKSPACE_OFFLINE with node name", () => {
const node = makeNode("ws-1", { name: "Beta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Beta is now offline");
});
it("announces WORKSPACE_PAUSED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma has been paused");
});
it("announces WORKSPACE_DEGRADED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_DEGRADED",
workspace_id: "ws-1",
payload: { sample_error: "connection timeout" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta is degraded");
});
it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: { name: "NewBot" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("NewBot is provisioning");
});
it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: {},
}),
get,
set
);
expect(state.liveAnnouncement).toBe("New Workspace is provisioning");
});
it("announces WORKSPACE_REMOVED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma was removed");
});
it("announces WORKSPACE_PROVISION_FAILED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISION_FAILED",
workspace_id: "ws-1",
payload: { error: "docker pull failed" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta provisioning failed");
});
it("does not announce for TASK_UPDATED", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "TASK_UPDATED",
workspace_id: "ws-1",
payload: { current_task: "building release", active_tasks: 1 },
}),
get,
set
);
// TASK_UPDATED is noisy (every heartbeat); it should not announce
expect(state.liveAnnouncement ?? "").toBe("");
});
it("does not announce for AGENT_MESSAGE", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "AGENT_MESSAGE",
workspace_id: "ws-1",
payload: { message: "hello from the agent" },
}),
get,
set
);
expect(state.liveAnnouncement ?? "").toBe("");
});
it("uses payload name for ONLINE when node not found in store", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_ONLINE",
workspace_id: "ws-1",
payload: { name: "FromPayload" },
}),
get,
set
);
// ONLINE when node doesn't exist just buffers _pendingOnline;
// no announcement should be set
expect(state.liveAnnouncement ?? "").toBe("");
});
});

View File

@ -1181,46 +1181,3 @@ describe("batchNest", () => {
expect(nestPatches).toHaveLength(1); expect(nestPatches).toHaveLength(1);
}); });
}); });
// ---------- moveNode ----------
describe("moveNode", () => {
beforeEach(() => {
const mock = global.fetch as ReturnType<typeof vi.fn>;
mock.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
);
mock.mockClear();
});
it("updates the node's position by the given delta", () => {
useCanvasStore.getState().hydrate([
makeWS({ id: "n1", name: "Node 1", x: 100, y: 200 }),
]);
useCanvasStore.getState().selectNode("n1");
useCanvasStore.getState().moveNode("n1", 10, -50);
const node = useCanvasStore.getState().nodes.find((n) => n.id === "n1")!;
expect(node.position).toEqual({ x: 110, y: 150 });
});
it("is a no-op when the node does not exist", () => {
useCanvasStore.getState().hydrate([makeWS({ id: "n1", name: "Node 1", x: 0, y: 0 })]);
expect(() => useCanvasStore.getState().moveNode("nonexistent", 10, 10)).not.toThrow();
});
it("calls savePosition with the new absolute coordinates", async () => {
useCanvasStore.getState().hydrate([makeWS({ id: "n1", name: "Node 1", x: 100, y: 200 })]);
useCanvasStore.getState().selectNode("n1");
const mock = global.fetch as ReturnType<typeof vi.fn>;
useCanvasStore.getState().moveNode("n1", 10, 20);
await vi.waitFor(() => {
expect(mock).toHaveBeenCalledWith(
expect.stringContaining("/workspaces/n1"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ x: 110, y: 220 }),
}),
);
});
});
});

View File

@ -80,7 +80,6 @@ export function handleCanvasEvent(
switch (msg.event) { switch (msg.event) {
case "WORKSPACE_ONLINE": { case "WORKSPACE_ONLINE": {
const existing = nodes.find((n) => n.id === msg.workspace_id); const existing = nodes.find((n) => n.id === msg.workspace_id);
const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace";
if (!existing) { if (!existing) {
// PROVISIONING event hasn't been applied yet (WS reorder or // PROVISIONING event hasn't been applied yet (WS reorder or
// this tab joined mid-deploy). Buffer so the later PROVISIONING // this tab joined mid-deploy). Buffer so the later PROVISIONING
@ -106,7 +105,6 @@ export function handleCanvasEvent(
? { ...n, data: { ...n.data, status: "online" } } ? { ...n, data: { ...n.data, status: "online" } }
: n, : n,
), ),
liveAnnouncement: `${nodeName} is now online`,
}); });
// Remove the laser class after its keyframe ends so the edge // Remove the laser class after its keyframe ends so the edge
// settles into the app's default solid styling. Fire-and-forget. // settles into the app's default solid styling. Fire-and-forget.
@ -125,36 +123,28 @@ export function handleCanvasEvent(
} }
case "WORKSPACE_OFFLINE": { case "WORKSPACE_OFFLINE": {
const offlineNode = nodes.find((n) => n.id === msg.workspace_id);
const offlineName = offlineNode?.data.name ?? "Workspace";
set({ set({
nodes: nodes.map((n) => nodes: nodes.map((n) =>
n.id === msg.workspace_id n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "offline" } } ? { ...n, data: { ...n.data, status: "offline" } }
: n : n
), ),
liveAnnouncement: `${offlineName} is now offline`,
}); });
break; break;
} }
case "WORKSPACE_PAUSED": { case "WORKSPACE_PAUSED": {
const pausedNode = nodes.find((n) => n.id === msg.workspace_id);
const pausedName = pausedNode?.data.name ?? "Workspace";
set({ set({
nodes: nodes.map((n) => nodes: nodes.map((n) =>
n.id === msg.workspace_id n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "paused", currentTask: "" } } ? { ...n, data: { ...n.data, status: "paused", currentTask: "" } }
: n : n
), ),
liveAnnouncement: `${pausedName} has been paused`,
}); });
break; break;
} }
case "WORKSPACE_DEGRADED": { case "WORKSPACE_DEGRADED": {
const degradedNode = nodes.find((n) => n.id === msg.workspace_id);
const degradedName = degradedNode?.data.name ?? "Workspace";
set({ set({
nodes: nodes.map((n) => nodes: nodes.map((n) =>
n.id === msg.workspace_id n.id === msg.workspace_id
@ -170,7 +160,6 @@ export function handleCanvasEvent(
} }
: n : n
), ),
liveAnnouncement: `${degradedName} is degraded`,
}); });
break; break;
} }
@ -241,7 +230,6 @@ export function handleCanvasEvent(
// removed per demo feedback. A2A edges (showA2AEdges) still // removed per demo feedback. A2A edges (showA2AEdges) still
// render when enabled — those represent runtime traffic, // render when enabled — those represent runtime traffic,
// which nesting doesn't express. // which nesting doesn't express.
const newNodeName = (msg.payload.name as string) ?? "New Workspace";
set({ set({
nodes: [ nodes: [
...nodes, ...nodes,
@ -256,7 +244,7 @@ export function handleCanvasEvent(
...(parentId ? { parentId } : {}), ...(parentId ? { parentId } : {}),
className: "mol-deploy-spawn", className: "mol-deploy-spawn",
data: { data: {
name: newNodeName, name: (msg.payload.name as string) ?? "New Workspace",
status: "provisioning", status: "provisioning",
tier: (msg.payload.tier as number) ?? 1, tier: (msg.payload.tier as number) ?? 1,
agentCard: null, agentCard: null,
@ -273,7 +261,6 @@ export function handleCanvasEvent(
}, },
}, },
], ],
liveAnnouncement: `${newNodeName} is provisioning`,
}); });
// Grow the parent to fit the just-landed child. DEBOUNCED // Grow the parent to fit the just-landed child. DEBOUNCED
@ -358,7 +345,6 @@ export function handleCanvasEvent(
case "WORKSPACE_REMOVED": { case "WORKSPACE_REMOVED": {
const removedNode = nodes.find((n) => n.id === msg.workspace_id); const removedNode = nodes.find((n) => n.id === msg.workspace_id);
const removedName = removedNode?.data.name ?? "Workspace";
const parentOfRemoved = removedNode?.data.parentId ?? null; const parentOfRemoved = removedNode?.data.parentId ?? null;
set({ set({
nodes: nodes nodes: nodes
@ -377,7 +363,6 @@ export function handleCanvasEvent(
e.source !== msg.workspace_id && e.target !== msg.workspace_id e.source !== msg.workspace_id && e.target !== msg.workspace_id
), ),
selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId, selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId,
liveAnnouncement: `${removedName} was removed`,
}); });
break; break;
} }
@ -460,8 +445,6 @@ export function handleCanvasEvent(
case "WORKSPACE_PROVISION_FAILED": { case "WORKSPACE_PROVISION_FAILED": {
const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error"; const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error";
const failedNode = nodes.find((n) => n.id === msg.workspace_id);
const failedName = failedNode?.data.name ?? "Workspace";
set({ set({
nodes: nodes.map((n) => nodes: nodes.map((n) =>
n.id === msg.workspace_id n.id === msg.workspace_id
@ -475,7 +458,6 @@ export function handleCanvasEvent(
} }
: n : n
), ),
liveAnnouncement: `${failedName} provisioning failed`,
}); });
break; break;
} }

View File

@ -165,13 +165,6 @@ interface CanvasState {
* this so a drag that pushed a child past the parent edge commits * this so a drag that pushed a child past the parent edge commits
* the parent grow on release (commit-on-release pattern). */ * the parent grow on release (commit-on-release pattern). */
growParentsToFitChildren: () => void; growParentsToFitChildren: () => void;
/** Move a selected node by (dx, dy) in canvas space. Used by keyboard
* arrow-key shortcuts so keyboard users can reposition nodes without a
* mouse. Persists the new position to the backend and skips the
* grow-parents pass that onNodesChange runs on every drag tick
* (avoids the "edge-chase" flicker that commit-on-release is meant to
* prevent). */
moveNode: (nodeId: string, dx: number, dy: number) => void;
/** Re-layout a parent's children to the default 2-column grid. Used /** Re-layout a parent's children to the default 2-column grid. Used
* by the "Arrange children" context-menu command so users can rescue * by the "Arrange children" context-menu command so users can rescue
* out-of-bounds children on demand topology no longer does it * out-of-bounds children on demand topology no longer does it
@ -232,11 +225,6 @@ interface CanvasState {
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */ /** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
showA2AEdges: boolean; showA2AEdges: boolean;
setShowA2AEdges: (show: boolean) => void; setShowA2AEdges: (show: boolean) => void;
/** Screen-reader announcement text. Set by handleCanvasEvent on significant
* status changes; consumed and cleared by the aria-live region in Canvas.tsx
* so the same announcement doesn't re-fire on re-render. */
liveAnnouncement: string;
setLiveAnnouncement: (msg: string) => void;
} }
export const useCanvasStore = create<CanvasState>((set, get) => ({ export const useCanvasStore = create<CanvasState>((set, get) => ({
@ -333,8 +321,6 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
localStorage.setItem("molecule:show-a2a-edges", String(show)); localStorage.setItem("molecule:show-a2a-edges", String(show));
} }
}, },
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
@ -1039,19 +1025,6 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
} }
}, },
moveNode: (nodeId, dx, dy) => {
const node = get().nodes.find((n) => n.id === nodeId);
if (!node) return;
set({
nodes: get().nodes.map((n) =>
n.id === nodeId
? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
: n,
),
});
void get().savePosition(nodeId, node.position.x + dx, node.position.y + dy);
},
savePosition: async (nodeId: string, x: number, y: number) => { savePosition: async (nodeId: string, x: number, y: number) => {
try { try {
await api.patch(`/workspaces/${nodeId}`, { x, y }); await api.patch(`/workspaces/${nodeId}`, { x, y });

View File

@ -7,22 +7,6 @@ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
exclude: ['e2e/**', 'node_modules/**', '**/dist/**'], exclude: ['e2e/**', 'node_modules/**', '**/dist/**'],
// Issue #22 / vitest pool investigation:
//
// The forks pool spawns one Node.js worker per concurrent slot.
// Each jsdom-environment worker bootstraps a full DOM (~30-50 MB resident
// set) at cold-start. With the default maxWorkers derived from CPU
// count, multiple jsdom workers can start simultaneously, exhausting
// memory on the 2-CPU Gitea Actions runner and causing pool workers to
// fail to respond with "[vitest-pool]: Timeout starting … runner."
//
// Fix: cap maxWorkers at 1 so only one worker is alive at any time.
// Tests still run in parallel within that single worker's process (via
// node's EventLoop) — this is the same parallelism as the `threads`
// pool but without the per-worker jsdom cold-start overhead. 51 test
// files that previously took 5070 s with 5 failures now run
// sequentially through one worker, eliminating the memory spike.
maxWorkers: 1,
// CI-conditional test timeout (issue #96). // CI-conditional test timeout (issue #96).
// //
// Vitest's 5000ms default is too tight for the first test in any // Vitest's 5000ms default is too tight for the first test in any

View File

@ -119,7 +119,7 @@ services:
networks: networks:
default: default:
name: molecule-core-net name: molecule-monorepo-net
external: true external: true
volumes: volumes:

View File

@ -1,7 +1,3 @@
# Include infra services (Temporal, Langfuse) so `docker compose up` starts the full stack.
include:
- docker-compose.infra.yml
services: services:
# --- Infrastructure --- # --- Infrastructure ---
postgres: postgres:
@ -16,8 +12,7 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
networks: networks:
- molecule-core-net - molecule-monorepo-net
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s interval: 2s
@ -44,7 +39,7 @@ services:
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse" psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi fi
networks: networks:
- molecule-core-net - molecule-monorepo-net
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@ -54,8 +49,7 @@ services:
volumes: volumes:
- redisdata:/data - redisdata:/data
networks: networks:
- molecule-core-net - molecule-monorepo-net
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 2s interval: 2s
@ -72,7 +66,7 @@ services:
volumes: volumes:
- clickhousedata:/var/lib/clickhouse - clickhousedata:/var/lib/clickhouse
networks: networks:
- molecule-core-net - molecule-monorepo-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s interval: 5s
@ -101,7 +95,7 @@ services:
ports: ports:
- "3001:3000" - "3001:3000"
networks: networks:
- molecule-core-net - molecule-monorepo-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"]
interval: 10s interval: 10s
@ -132,10 +126,6 @@ services:
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
PORT: "${PLATFORM_PORT:-8080}" PORT: "${PLATFORM_PORT:-8080}"
PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}" PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}"
# Container network namespace is already isolated; "all interfaces"
# inside the container = the bridge interface only. The fail-open
# default (127.0.0.1) would block host-to-container access.
BIND_ADDR: "${BIND_ADDR:-0.0.0.0}"
# Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth # Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth
# middleware fail-open path activates when ADMIN_TOKEN is unset — # middleware fail-open path activates when ADMIN_TOKEN is unset —
# otherwise the canvas (which runs without a bearer in pure local # otherwise the canvas (which runs without a bearer in pure local
@ -205,28 +195,12 @@ services:
# App private key — read-only bind-mount. The host-side path is # App private key — read-only bind-mount. The host-side path is
# gitignored per .gitignore rules (/.secrets/ + *.pem). # gitignored per .gitignore rules (/.secrets/ + *.pem).
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro - ./.secrets/github-app.pem:/secrets/github-app.pem:ro
# Per-role persona credentials (molecule-core#242 local surface).
# Sourced at workspace creation time by org_import.go::loadPersonaEnvFile
# when a workspace.yaml carries `role: <name>`. The host-side dir is
# populated by the operator-host bootstrap kit (28 dev-tree personas);
# /etc/molecule-bootstrap/personas is the in-container path the
# platform expects (matches the prod tenant-EC2 path so the same code
# works in both modes).
#
# Read-only mount — workspace-server only reads, never writes here.
# If the host dir is empty/missing the platform's loadPersonaEnvFile
# silently no-ops per its existing semantics, so this mount is safe
# even on a fresh machine that hasn't run the bootstrap kit yet.
- ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}:/etc/molecule-bootstrap/personas:ro
ports: ports:
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}" - "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
networks: networks:
- molecule-core-net - molecule-monorepo-net
restart: unless-stopped
healthcheck: healthcheck:
# Plain GET — `--spider` would issue HEAD, which returns 404 because test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
# /health is registered as GET only.
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@ -262,9 +236,9 @@ services:
ports: ports:
- "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}" - "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}"
networks: networks:
- molecule-core-net - molecule-monorepo-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@ -295,7 +269,7 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule} LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule}
networks: networks:
- molecule-core-net - molecule-monorepo-net
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"]
@ -320,7 +294,7 @@ services:
volumes: volumes:
- ollamadata:/root/.ollama - ollamadata:/root/.ollama
networks: networks:
- molecule-core-net - molecule-monorepo-net
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"] test: ["CMD-SHELL", "ollama list || exit 1"]
@ -330,8 +304,8 @@ services:
start_period: 20s start_period: 20s
networks: networks:
molecule-core-net: molecule-monorepo-net:
name: molecule-core-net name: molecule-monorepo-net
volumes: volumes:
pgdata: pgdata:

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