Compare commits

..

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

219 changed files with 1026 additions and 13661 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,346 +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 team
# membership against the tier's approval expression. Passes only when
# ALL clauses in the expression are satisfied by the set of approving
# reviewers (AND-composition; internal#189).
#
# Expression syntax:
# "team-a" — OR-set: any ONE of the comma-separated teams
# "team-a AND team-b" — AND: BOTH must each have ≥1 approver
# "(a,b,c)" — OR-set wrapped in parens; same as "a,b,c"
#
# Example: "qa AND security AND (managers,ceo)" means:
# ≥1 approver in team "qa" AND
# ≥1 approver in team "security" AND
# ≥1 approver in team "managers" OR "ceo"
#
# Per the spec (internal#189), the hard gate here pairs with the
# advisory gate of sop-conformance LLM-judge (internal#188): each
# required-team click must reflect real verification (visible in review
# body or A2A messages), not rubber-stamp APPROVE. Both gates together
# close the "teammate clicks APPROVE without verifying" gap.
#
# 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. Default: off.
# SOP_LEGACY_CHECK=1 — revert to OR-gate (≥1 approver from any eligible
# team). Grace window for PRs in-flight when the
# new AND-composition was deployed. Expires 2026-05-17
# (7-day burn-in window; internal#189 Phase 1).
# Set by workflow for PRs merged before the deploy.
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 → required team expression (AND-composition; internal#189)
#
# Expression syntax:
# clause-a AND clause-b AND ... — ALL clauses must pass
# team-a,team-b,team-c — OR-set: ≥1 approver in ANY of these teams
# (team-a,team-b) — same as team-a,team-b (parens optional)
#
# This map is the single source of truth. Update it when the team structure
# or policy changes. Teams referenced here but absent in Gitea are treated
# as unachievable (would always fail) — operators notice the clear error
# and create the missing team.
#
# Current Gitea teams: ceo, engineers, managers
# Future teams (create before removing "???" fallback): qa, security, security-audit
declare -A TIER_EXPR=(
# tier:low — same as previous OR gate: any engineer, manager, or ceo.
["tier:low"]="engineers,managers,ceo"
# tier:medium — AND of (managers) AND (engineers) AND (qa???,security???)
# The qa+security clause requires both teams to exist; when not yet
# created, the PR author is responsible for adding them before requesting
# approval on a tier:medium PR. Ops: create qa + security Gitea teams
# and update this map to remove the "???" markers (internal#189 follow-up).
["tier:medium"]="managers AND engineers AND qa???,security???"
# tier:high — ceo only. The AND-composition adds no value for a
# single-team gate, but the framework is wired for consistency.
["tier:high"]="ceo"
)
EXPR="${TIER_EXPR[$TIER]-}"
if [ -z "$EXPR" ]; then
echo "::error::No expression defined for tier $TIER in TIER_EXPR map."
exit 1
fi
debug "expression=$EXPR"
# 3. Legacy OR-gate override (7-day burn-in grace window; internal#189 Phase 1)
if [ "${SOP_LEGACY_CHECK:-}" = "1" ]; then
LEGACY_ELIGIBLE=""
case "$TIER" in
tier:low) LEGACY_ELIGIBLE="engineers managers ceo" ;;
tier:medium) LEGACY_ELIGIBLE="managers ceo" ;;
tier:high) LEGACY_ELIGIBLE="ceo" ;;
esac
echo "::notice::SOP_LEGACY_CHECK=1 — using OR-gate ({$LEGACY_ELIGIBLE}) for this PR."
ELIGIBLE="$LEGACY_ELIGIBLE"
fi
# 4. Resolve all team names → IDs
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
# we 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."
exit 1
fi
# Collect every team name that appears in the expression.
# Bash word-splitting on $EXPR splits on spaces, so "AND" appears as a
# token. We skip it explicitly.
declare -A TEAM_ID
_all_teams=""
for _raw_clause in $EXPR; do
# Strip parens and split on comma.
_clause=${_raw_clause//[()]/}
for _t in $(echo "$_clause" | tr ',' '\n'); do
_t=$(echo "$_t" | tr -d '[:space:]')
[ -z "$_t" ] && continue
# Skip AND / OR operator tokens (bash word-split produced them from
# spaces in the expression string).
[ "$_t" = "AND" ] || [ "$_t" = "OR" ] && continue
# Skip if already in set.
case " $_all_teams " in
*" $_t "*) ;; # already present
*) _all_teams="${_all_teams} $_t " ;;
esac
done
done
for _t in $_all_teams; do
_t=$(echo "$_t" | tr -d ' ')
[ -z "$_t" ] && continue
_id=$(jq -r --arg t "$_t" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
if [ -z "$_id" ] || [ "$_id" = "null" ]; then
# "??" suffix marks teams that don't exist yet (tier:medium qa/security).
# Treat as permanently failing clause; clear error message guides ops.
if [[ "$_t" == *"???" ]]; then
debug "team \"$_t\" not found (expected — pending team creation per internal#189)"
continue
fi
_visible=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
echo "::error::Team \"$_t\" referenced in tier $TIER expression but not found in org $OWNER. Teams visible: $_visible"
exit 1
fi
TEAM_ID[$_t]="$_id"
debug "team-id: $_t$_id"
done
# 5. 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 on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
exit 1
fi
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
# 6. For each approver: skip self-review; probe team membership by id.
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
# Pre/post spaces ensure case patterns *${_t}* match even when the name
# is the first or last entry (bash case *word* needs delimiters on both sides).
#
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
# member — a superset of team membership. Accepting it as a fallback means
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
# This is safe because: (a) org membership is a prerequisite for every
# eligible team; (b) the AND-composition of internal#189 still requires
# multiple independent approvers; (c) any token with read:repository can
# see the approving reviews, so bypass requires a colluding approver.
declare -A APPROVER_TEAMS
for U in $APPROVERS; do
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
_any_team_success="no"
for T in "${!TEAM_ID[@]}"; 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
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
debug "$U qualifies for team $T"
_any_team_success="yes"
fi
done
# Fallback: if every team probe returned 403, try org membership.
# "??" teams were never resolved to IDs so they never entered the loop.
# If the user is an org member, credit them as being in each queried team
# (engineers, managers, ceo are all org-level). This is safe because org
# membership is a prerequisite for all three, and bypass requires a colluding
# approver (same risk as before the AND-composition).
if [ "$_any_team_success" = "no" ]; then
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/members/${U}")
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
if [ "$ORG_CODE" = "204" ]; then
for T in "${!TEAM_ID[@]}"; do
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
done
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
fi
fi
done
# 7. Evaluate the tier expression.
#
# legacy OR-gate: use the simplified loop from before internal#189.
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
OK=""
for _u in "${!APPROVER_TEAMS[@]}"; do
for _t2 in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$_u]}" in
*${_t2}*)
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
OK="yes"
break
;;
esac
done
[ -n "$OK" ] && break
done
if [ -z "$OK" ]; then
echo "::error::Tier $TIER requires approval from a non-author member of {$LEGACY_ELIGIBLE}. Set SOP_DEBUG=1 to see per-probe HTTP codes."
exit 1
fi
echo "::notice::sop-tier-check passed: $TIER (legacy OR-gate)"
exit 0
fi
# AND-gate: evaluate the expression clause by clause.
# _passed_clauses and _failed_clauses accumulate for the status description.
_passed_clauses=""
_failed_clauses=""
for _raw_clause in $EXPR; do
# Normalise: strip parens, replace commas with spaces so bash word-split
# can iterate the OR-set members. The previous form
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# collapsed every member into one concatenated token because
# `tr -d '[:space:]'` strips the very newlines that just separated them
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
# only ever evaluated as a single nonsense team name and never matched
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
# space-separated tokens for `for _t in $_clause`.
_no_parens=${_raw_clause//[()]/}
_clause=${_no_parens//,/ }
_clause_passed="no"
_clause_names=""
for _t in $_clause; do
# Append (don't overwrite) team name to the human-readable accumulator.
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
# rewrote the variable on every iteration, so the FAIL message only
# ever showed the LAST team. Fixed: prepend prior value before the
# comma-separator, then append the new team name.
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
for _u in "${!APPROVER_TEAMS[@]}"; do
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
# Pattern *${_t}* matches team name anywhere in the space-padded string.
case "${APPROVER_TEAMS[$_u]}" in
*${_t}*)
_clause_passed="yes"
debug "clause \"$_t\": satisfied by $_u"
break
;;
esac
done
done
# Label for display: strip "???" from pending teams.
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
if [ "$_clause_passed" = "yes" ]; then
# Append (don't overwrite) — same accumulator bug as _clause_names above.
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
else
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
fi
done
if [ -n "$_failed_clauses" ]; then
echo ""
echo "::error::sop-tier-check FAILED for $TIER."
echo " Passed :${_passed_clauses}"
echo " Missing:${_failed_clauses}"
echo " All clauses must be satisfied. Each missing team needs an APPROVED review from one of its members."
exit 1
fi
echo "::notice::sop-tier-check PASSED: $TIER — all required clauses satisfied [${_passed_clauses}]"

View File

@ -1,101 +0,0 @@
#!/usr/bin/env bash
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
#
# Bug (PR #225 → still broken after PR #231):
# Line ~289 of sop-tier-check.sh used:
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
# inserted, collapsing "engineers,managers,ceo" into a single token
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
# that matches no team, so every tier:low PR fails:
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
# reviewer belongs to any of these teamsengineersmanagersceo
# (note also: missing separators in the error string is bug #2 —
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
#
# Fix shape (this PR):
# _no_parens=${_raw_clause//[()]/}
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
#
# This test extracts the splitter logic and asserts it produces the right
# token list for each of the three tier expressions live in the script.
set -euo pipefail
PASS=0
FAIL=0
assert_eq() {
local label="$1"
local expected="$2"
local got="$3"
if [ "$expected" = "$got" ]; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label"
echo " expected: <$expected>"
echo " got: <$got>"
FAIL=$((FAIL + 1))
fi
}
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
split_clause() {
local raw="$1"
local no_parens=${raw//[()]/}
local clause=${no_parens//,/ }
local out=""
for _t in $clause; do
out="${out}${out:+|}$_t"
done
echo "$out"
}
echo "test: tier:low OR-clause splits to 3 tokens"
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
EXPR="managers AND engineers AND qa???,security???"
out=""
for _raw in $EXPR; do
out="${out}${out:+ ; }$(split_clause "$_raw")"
done
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
echo "test: tier:high single-team OR-clause"
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
echo "test: paren-wrapped OR-set unwraps + splits"
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
# ----- _clause_names accumulator (was overwriting per iteration) -----
acc=""
for t in engineers managers ceo; do
acc="${acc}${acc:+, }${t}"
done
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
acc=""
for c in clauseA clauseB clauseC; do
acc="${acc}${acc:+, }${c}"
done
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
# The script's case pattern is *${_t}* with a space-padded value.
APPROVER_TEAMS_VAL=" managers "
matched=""
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
case "$APPROVER_TEAMS_VAL" in
*${_t}*) matched="$_t"; break ;;
esac
done
assert_eq "OR-gate matches managers" "managers" "$matched"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

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,174 +0,0 @@
name: publish-workspace-server-image
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
#
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment:` declarations — Gitea Actions does not support
# named environments (used by GitHub OIDC token gates)
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
# work identically here
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
# secrets.*) use the same syntax as GitHub Actions
#
# Image tags produced:
# :staging-<sha> — per-commit digest, stable for canary verify
# :staging-latest — tracks most recent build on this branch
#
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
concurrency:
group: publish-workspace-server-image-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible (e.g. permission change, daemon restart, or group-membership
# drift) rather than silently continuing to step 2 where `docker build`
# fails deep in the process with a cryptic ECR auth error that doesn't
# surface the root cause. Also reports the daemon version so operator
# can correlate with runner host logs.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
#
# Why: workspace-template-* repos on Gitea are private. The pre-fix
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
# auth path — every CI build failed. We clone in the trusted CI
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
# just COPYs from .tenant-bundle-deps/.
#
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
# clone-manifest.sh embeds it as basic-auth for the clones, then
# strips .git dirs — the token never enters the image.
- name: Pre-clone manifest deps
env:
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is empty"
exit 1
fi
mkdir -p .tenant-bundle-deps
bash scripts/clone-manifest.sh \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
- name: Compute tags
id: tags
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Build + push platform image (inline ECR auth — mirrors the operator-host
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
--tag "${IMAGE_NAME}:${TAG_SHA}" \
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${IMAGE_NAME}:${TAG_SHA}"
docker push "${IMAGE_NAME}:${TAG_LATEST}"
# Build + push tenant image (Go platform + Next.js canvas in one image).
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"

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,100 +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 → required-team expression (internal#189 AND-composition):
# tier:low → engineers,managers,ceo (OR: any one suffices)
# tier:medium → managers AND engineers AND qa???,security??? (AND: all required)
# tier:high → ceo (OR: single team, wired for AND)
#
# "???" = teams not yet created in Gitea. When qa + security teams are
# added, update TIER_EXPR["tier:medium"] in the script to remove the
# markers. PRs already in-flight when qa/security are created continue
# to work because their authors explicitly requested those reviews.
#
# 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).
#
# Environment variables:
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
# for PRs in-flight when AND-composition deployed.
# Burn-in: remove after 2026-05-17 (7-day window).
#
# BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
# the tier-check job below. This prevents AND-composition from blocking
# PRs during the 7-day burn-in. After 2026-05-17:
# 1. Remove `continue-on-error: true` from this job block.
# 2. Update this BURN-IN NOTE comment to mark the window closed.
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
# BURN-IN: continue-on-error prevents AND-composition from blocking
# PRs during the 7-day window. Remove after 2026-05-17 (internal#189).
continue-on-error: true
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'
# BURN-IN: set to '1' for PRs in-flight at AND-composition deploy
# time to use the legacy OR-gate. Remove after 2026-05-17.
SOP_LEGACY_CHECK: '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.
- cron: '*/30 * * * *'
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
# 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.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
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:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -158,28 +137,27 @@ jobs:
id: canary
run: bash tests/e2e/test_staging_full_saas.sh
# Alerting: open a sticky issue on the FIRST failure; comment on
# subsequent failures; auto-close on next green. Comment-on-existing
# de-duplicates so a single open issue accumulates the streak —
# ops sees one issue with N comments rather than N issues.
# Alerting: open an issue only after THREE consecutive failures so
# transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
# the issue list. If an issue is already open, we still comment on
# every failure so ops sees the streak. Auto-close on next green.
#
# Why no consecutive-failures threshold (e.g., wait 3 runs before
# filing): the prior threshold check used
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does
# not expose (returns 404). On Gitea Actions the threshold call
# 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.
# Threshold rationale: canary fires every 30 min, so 3 failures =
# ~90 min of consecutive red — well past any single-run flake but
# still tight enough that a real outage gets surfaced before the
# next deploy window.
- name: Open issue on failure
if: failure()
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:
script: |
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).
// If one exists, this isn't a "first failure" — comment and exit.
@ -199,12 +177,32 @@ jobs:
return;
}
// No open issue yet — file one on this first failure. The
// comment-on-existing branch above means subsequent failures
// accumulate as comments on this same issue, so we don't
// spam new issues per run.
// No open issue yet — check the last N-1 runs' conclusions.
// We open the issue only if the last (THRESHOLD-1) runs ALSO
// failed (so this is the 3rd consecutive red).
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 =
`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` +
`This issue auto-closes on the next green canary run. ` +
`Consecutive failures add a comment here rather than a new issue.`;
@ -213,7 +211,7 @@ jobs:
title, body,
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
if: success()

View File

@ -1,34 +1,19 @@
name: canary-verify
# Runs the canary smoke suite against the staging canary tenant fleet
# after a new :staging-<sha> image lands in ECR. On green, calls the
# CP redeploy-fleet endpoint to promote :staging-<sha> → :latest so
# the prod tenant fleet's 5-minute auto-updater picks up the verified
# digest. On red, :latest stays on the prior known-good digest and
# prod is untouched.
#
# Registry note (2026-05-10): This workflow previously used GHCR
# (ghcr.io/molecule-ai/platform-tenant) — that registry was retired
# during the 2026-05-06 Gitea suspension migration when publish-
# workspace-server-image.yml switched to the operator's ECR org
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/
# platform-tenant). The GHCR → ECR migration was never applied to
# this file, so canary-verify was silently smoke-testing the stale
# GHCR image while the actual staging/prod tenants ran the ECR image.
# Result: smoke tests could not catch a broken ECR build. Fix:
# - Wait step: reads SHA from running canary /health (tenant-
# agnostic, works regardless of registry).
# - Promote step: calls CP redeploy-fleet endpoint with target_tag=
# staging-<sha>, same mechanism as redeploy-tenants-on-main.yml.
# No longer attempts GHCR crane ops.
# after a new :staging-<sha> image lands in GHCR. On green, promotes
# :staging-<sha> → :latest so the prod tenant fleet's 5-minute
# auto-updater picks up the verified digest. On red, :latest stays
# on the prior known-good digest and prod is untouched.
#
# Dependencies:
# - publish-workspace-server-image.yml publishes :staging-<sha>
# to ECR on staging and main merges.
# - Canary tenants are configured to pull :staging-<sha> from ECR
# (TENANT_IMAGE env set to the ECR :staging-<sha> tag).
# (NOT :latest) on main merge
# - canary tenants are configured to pull :staging-<sha> as their
# tenant image (set TENANT_IMAGE=ghcr.io/…:staging-<sha> on the
# canary provisioner code path OR rotate via an admin endpoint)
# - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS /
# CANARY_CP_SHARED_SECRET are populated.
# CANARY_CP_SHARED_SECRET are populated
on:
workflow_run:
@ -42,12 +27,8 @@ permissions:
actions: read
env:
# ECR registry (post-2026-05-06 SSOT for tenant images).
# publish-workspace-server-image.yml pushes here.
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
# CP endpoint for redeploy-fleet (used in promote step below).
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
IMAGE_NAME: ghcr.io/molecule-ai/platform
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
jobs:
canary-smoke:
@ -71,12 +52,6 @@ jobs:
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
# proceeding after 7 min even if not all canaries responded —
# the smoke suite will catch any that didn't update.
#
# NOTE: The SHA is read from the running tenant's /health response,
# NOT from a registry lookup. This is registry-agnostic and works
# regardless of whether the tenant pulls from ECR, GHCR, or any
# other registry — the canary is telling us what it's actually
# running, which is the ground truth for smoke testing.
env:
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
@ -158,98 +133,42 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
promote-to-latest:
# On green, calls the CP redeploy-fleet endpoint with target_tag=
# staging-<sha> to promote the verified ECR image. This is the same
# mechanism as redeploy-tenants-on-main.yml — no GHCR crane ops.
#
# Pre-fix history: the old GHCR promote step used `crane tag` against
# ghcr.io/molecule-ai/platform-tenant, but publish-workspace-server-
# image.yml had already migrated to ECR on 2026-05-07 (commit
# 10e510f5). The GHCR tags were never updated, so this step was
# silently promoting a stale GHCR image while actual prod tenants
# pulled from ECR. Canary smoke tests were GHCR-targeted and could
# not catch a broken ECR build.
# On green, retag :staging-<sha> → :latest for BOTH images.
# crane is a lightweight registry client (no Docker daemon needed on
# the runner) that can retag remotely with a single API call each.
# Gated on smoke_ran=true — without a real canary fleet the smoke
# step no-ops with success, and we don't want that to silently
# auto-promote every main merge.
needs: canary-smoke
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
runs-on: ubuntu-latest
env:
SHA: ${{ needs.canary-smoke.outputs.sha }}
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
# CP_ADMIN_API_TOKEN gates write access to the redeploy endpoint.
# Stored at the repo level so all workflows pick it up automatically.
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
# canary_slug pin: deploy the verified :staging-<sha> to the canary
# first (soak 120s), then fan out to the rest of the fleet.
CANARY_SLUG: ${{ vars.CANARY_PROMOTE_SLUG || '' }}
SOAK_SECONDS: ${{ vars.CANARY_PROMOTE_SOAK || '120' }}
BATCH_SIZE: ${{ vars.CANARY_PROMOTE_BATCH || '3' }}
steps:
- name: Check CP credentials
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
run: |
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret is not set — promote step cannot call redeploy-fleet."
echo "::error::Set it at: repo Settings → Actions → Variables and Secrets → New Secret."
exit 1
fi
echo "${{ secrets.GITHUB_TOKEN }}" | \
crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Promote verified ECR image to :latest
- name: Retag platform :staging-<sha> → :latest
run: |
set -euo pipefail
crane tag \
"${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
latest
TARGET_TAG="staging-${SHA}"
BODY=$(jq -nc \
--arg tag "$TARGET_TAG" \
--argjson soak "${SOAK_SECONDS:-120}" \
--argjson batch "${BATCH_SIZE:-3}" \
--argjson dry false \
'{
target_tag: $tag,
soak_seconds: $soak,
batch_size: $batch,
dry_run: $dry
}')
if [ -n "${CANARY_SLUG:-}" ]; then
BODY=$(jq '. * {canary_slug: $slug}' --arg slug "$CANARY_SLUG" <<<"$BODY")
fi
echo "Calling: POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" >"$HTTP_CODE_FILE"
CURL_EXIT=$?
set -e
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE (curl exit $CURL_EXIT)"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
if [ "$HTTP_CODE" -ge 400 ]; then
echo "::error::CP redeploy-fleet returned HTTP $HTTP_CODE — refusing to proceed."
exit 1
fi
- name: Retag tenant :staging-<sha> → :latest
run: |
crane tag \
"${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
latest
- name: Summary
run: |
{
echo "## Canary verified — :latest promoted via CP redeploy-fleet"
echo ""
echo "- **Target tag:** \`staging-${{ needs.canary-smoke.outputs.sha }}\`"
echo "- **Registry:** ECR (\`${TENANT_IMAGE_NAME}\`)"
echo "- **Canary slug:** \`${CANARY_SLUG:-<none>}\` (soak ${SOAK_SECONDS}s)"
echo "- **Batch size:** ${BATCH_SIZE:-3}"
echo ""
echo "CP redeploy-fleet is rolling out the verified image across the prod fleet."
echo "The fleet's 5-minute health-check loop will pick up the update automatically."
echo "## Canary verified — :latest promoted"
echo
echo "- \`${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${IMAGE_NAME}:latest\`"
echo "- \`${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${TENANT_IMAGE_NAME}:latest\`"
echo
echo "Prod tenant fleet will pick up the new digest on its next 5-min auto-update cycle."
} >> "$GITHUB_STEP_SUMMARY"

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),
# but staging is what the merge queue runs on, so it's the trigger that
# 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:
pull_request:
@ -32,6 +25,9 @@ on:
paths:
- '.github/workflows/**.yml'
- '.github/workflows/**.yaml'
# Self-listen on merge_group so the linter passes its own queue run.
merge_group:
types: [checks_requested]
jobs:
check:
@ -40,9 +36,88 @@ jobs:
permissions:
contents: read
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: |
echo "Gitea Actions — merge queue not supported; no-op."
echo "On GitHub this workflow lints that required-check workflows declare"
echo "merge_group: triggers to prevent queue deadlock. On Gitea that"
echo "constraint is inapplicable — all workflows pass vacuously."
set -euo pipefail
# Branch we care about — the one merge queue runs on.
BRANCH=staging
# Pull the list of required status check contexts. If the branch
# has no protection or no required checks, exit clean — nothing
# to lint.
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
--jq '.contexts[]' 2>/dev/null || true)
if [ -z "$REQUIRED" ]; then
echo "No required status checks on ${BRANCH} — nothing to verify."
exit 0
fi
echo "Required checks on ${BRANCH}:"
echo "${REQUIRED}" | sed 's/^/ - /'
echo
# Build a map: workflow file -> set of job names declared in it.
# We use yq if available, otherwise grep the `name:` lines under
# `jobs:`. Stick with grep for portability — runner image always
# has it; yq isn't in the default image as of 2026-04.
declare -A workflow_jobs
shopt -s nullglob
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$wf" ] || continue
# Extract the workflow name (the `name:` at file root).
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
# Extract job step names from the `jobs:` block. A job step is:
# - id under `jobs:` (key with 2-space indent followed by colon)
# - the `name:` field inside that job (4-space indent)
# We collect both because required_status_checks contexts can
# match either, depending on how the workflow was authored.
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
done
# For each required check, find the workflow that produces it.
# Then verify that workflow lists merge_group as a trigger.
FAILED=0
while IFS= read -r check; do
[ -z "$check" ] && continue
owning_wf=""
for wf in "${!workflow_jobs[@]}"; do
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
owning_wf="$wf"
break
fi
done
if [ -z "$owning_wf" ]; then
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
continue
fi
# Does the workflow's trigger list include merge_group?
# Match either bare `merge_group:` line or merge_group with
# subsequent indented config (types: [checks_requested]).
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
else
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
echo "::error file=${owning_wf}:: merge_group:"
echo "::error file=${owning_wf}:: types: [checks_requested]"
FAILED=1
fi
done <<< "$REQUIRED"
if [ "$FAILED" -ne 0 ]; then
echo
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
exit 1
fi
echo
echo "All required workflows on ${BRANCH} declare merge_group triggers."

View File

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

View File

@ -51,7 +51,7 @@ name: E2E API Smoke Test
# * Pre-pull `alpine:latest` so the platform-server's provisioner
# (`internal/handlers/container_files.go`) can stand up its
# 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
# succeeds.
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
@ -163,12 +163,12 @@ jobs:
# when the image is already present.
docker pull alpine:latest >/dev/null
# 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
# the operator host's docker daemon — `network create` is
# idempotent via `|| true`.
docker network create molecule-core-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured."
docker network create molecule-monorepo-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.api == 'true'
run: |

View File

@ -34,7 +34,7 @@ name: Handlers Postgres Integration
# So we sidestep `services:` entirely. The job container still uses
# host-net (inherited from runner config; required for cache server
# 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
# read its bridge IP via `docker inspect`. A host-net job container
# 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
# + `if: always()` cleanup runs even on test-step failure
# - 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)
#
# 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 }}
# Bridge network already exists on the operator host (declared
# in docker-compose.yml + docker-compose.infra.yml).
PG_NETWORK: molecule-core-net
PG_NETWORK: molecule-monorepo-net
defaults:
run:
working-directory: workspace-server

View File

@ -56,40 +56,21 @@ jobs:
run: ${{ steps.decide.outputs.run }}
steps:
- 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
run: |
# workflow_dispatch: always run (manual trigger)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
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
# New branch or github.event.before unavailable — run everything.
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"
echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
fi
# ONE job that always runs. Real work is gated per-step on
@ -110,17 +91,10 @@ jobs:
run: |
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::Debug: ${{ needs.detect-changes.outputs.debug }}"
- if: needs.detect-changes.outputs.run == 'true'
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):
# 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
# 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
# is the devops-engineer persona PAT, NOT the founder PAT (per
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh

View File

@ -54,22 +54,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
- name: Compute tags
id: tags
shell: bash

View File

@ -1,15 +1,5 @@
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/.
# Monorepo workspace/ is the only source-of-truth for runtime code; this
# workflow is the bridge from monorepo edits to the PyPI artifact that
@ -180,7 +170,7 @@ jobs:
# environment pypi-publish. The action mints a short-lived OIDC
# token and exchanges it for a PyPI upload credential — no static
# API token in this repo's secrets.
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ${{ runner.temp }}/runtime-build/dist/

View File

@ -32,7 +32,7 @@ name: publish-workspace-server-image
on:
push:
branches: [main]
branches: [staging, main]
paths:
- 'workspace-server/**'
- 'canvas/**'
@ -107,22 +107,6 @@ jobs:
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build (Task #173 fix).
#
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on

View File

@ -3,9 +3,9 @@ name: redeploy-tenants-on-main
# Auto-refresh prod tenant EC2s after every main merge.
#
# Why this workflow exists: publish-workspace-server-image builds and
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
# but running tenants pulled their image once at boot and never re-pull.
# Users see stale code indefinitely.
# pushes a new platform-tenant:latest + :<sha> to GHCR on every merge
# to main, but running tenants pulled their image once at boot and
# never re-pull. Users see stale code indefinitely.
#
# This workflow closes the gap by calling the control-plane admin
# endpoint that performs a canary-first, batched, health-gated rolling
@ -13,18 +13,12 @@ name: redeploy-tenants-on-main
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
# (feat/tenant-auto-redeploy, landing alongside this workflow).
#
# Registry: ECR (153263036946.dkr.ecr.us-east-2.amazonaws.com/
# molecule-ai/platform-tenant). GHCR was retired 2026-05-07 during the
# Gitea suspension migration. The canary-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
#
# Runtime ordering:
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
# 2. This workflow fires via workflow_run, calls redeploy-fleet with
# target_tag=staging-<sha>. No CDN propagation wait needed —
# ECR image manifest is consistent immediately after push.
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
# period. Canary proves the image boots; batches follow.
# 1. publish-workspace-server-image completes → new :latest in GHCR.
# 2. This workflow fires via workflow_run, waits 30s for GHCR's
# CDN to propagate the new tag to the region the tenants pull from.
# 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
# soak. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
#
@ -114,11 +108,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Note on ECR propagation
# ECR image manifests are consistent immediately after push — no
# CDN cache to wait for. The old GHCR-based workflow had a 30s
# sleep to avoid race conditions; ECR makes that unnecessary.
run: echo "ECR image available immediately after push — proceeding."
- name: Wait for GHCR tag propagation
# GHCR's edge cache takes ~15-30s to consistently serve the new
# manifest after the registry accepts the push. Without this
# sleep, the first tenant's docker pull sometimes races and
# fetches the previous digest; sleeping is the cheapest way to
# reduce that without polling GHCR for the new digest.
run: sleep 30
- name: Compute target tag
id: tag

View File

@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View File

@ -284,7 +284,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
# 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.
#
# Also populates the template/plugin registry by cloning every repo

View File

@ -283,7 +283,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权,
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
#
# 同时会根据 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
COPY package.json package-lock.json* ./
# `npm ci` (not `install`) for lockfile-exact reproducibility.
# `--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
RUN npm install
COPY . .
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
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
RUN npm run build
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
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"`
// bakes resolved env into the build, and the marker file isn't shipped.
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 = {
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 {
let dir = start;
for (let i = 0; i < 6; i++) {

View File

@ -354,7 +354,7 @@ function OrgCTA({ org }: { org: Org }) {
);
}
// 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 }) {
@ -420,7 +420,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
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"
/>
<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.
</p>
</div>

View File

@ -56,7 +56,7 @@ export default function Home() {
<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">
<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>
);
@ -119,11 +119,11 @@ function PlatformDownDiagnostic() {
Most common cause on a dev host: one of those services stopped.
</p>
<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
brew services start redis`}</pre>
</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
the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment.
</p>

View File

@ -55,13 +55,13 @@ export default function PricingPage() {
</a>
.
</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.
Enterprise / self-hosted licensing available contact us.
</p>
</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>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-ink-mid">

View File

@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) {
return (
<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>
);
}
@ -145,7 +145,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "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}
@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{entries.length === 0 ? (
/* Empty state */
<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-[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.
</p>
</div>
@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
)}
{/* 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
{cursor ? " · more available" : " · all loaded"}
</p>
@ -265,7 +265,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
)}
{/* 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)}
</span>
</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="text-3xl mb-2" aria-hidden="true">📦</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>
)}

View File

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useMemo } from "react";
import {
ReactFlow,
ReactFlowProvider,
@ -187,23 +187,6 @@ function CanvasInner() {
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
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
// unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler.
@ -343,21 +326,11 @@ function CanvasInner() {
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region announces workspace count on initial load and
live status updates from WebSocket events (online, offline, provisioning, etc.).
The liveAnnouncement text is cleared after the screen reader has had time
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"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`
)}
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}

View File

@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
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>
</button>
@ -268,7 +268,7 @@ export function CommunicationOverlay() {
</div>
</div>
{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 && (
<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
</h3>
{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}
</div>
)}
@ -124,7 +124,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4">
{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
</div>
)}

View File

@ -311,7 +311,7 @@ export function ContextMenu() {
aria-hidden="true"
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>

View File

@ -13,8 +13,7 @@ interface Props {
onClose: () => void;
}
/** Exported for unit testing — see ConversationTraceModal.test.ts */
export function extractMessageText(body: Record<string, unknown> | null): string {
function extractMessageText(body: Record<string, unknown> | null): string {
if (!body) return "";
try {
// Simple task format from MCP server: {task: "..."}
@ -107,7 +106,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace
</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
</p>
</div>
@ -115,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
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>
@ -125,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{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...
</div>
)}
{!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
</div>
)}
@ -251,7 +250,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Message content — show request and/or response */}
{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="text-[8px] text-ink-mid uppercase mb-1">
<div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<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">
Create Workspace
</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
</p>
@ -376,7 +376,7 @@ export function CreateWorkspaceButton() {
/>
<div className="text-xs">
<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.
</div>
</div>
@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
Hermes Provider
</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
stored as an encrypted workspace secret.
</p>
@ -534,7 +534,7 @@ export function CreateWorkspaceButton() {
(m) => <option key={m} value={m} />,
)}
</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.
</p>
</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" : ""}`}
/>
{helper && (
<p className="mt-1 text-xs text-ink-mid">{helper}</p>
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
)}
</div>
);

View File

@ -129,11 +129,11 @@ export function EmptyState() {
T{t.tier}
</span>
</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"}
</p>
{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.model ? ` · ${t.model}` : ""}
</p>
@ -174,10 +174,10 @@ export function EmptyState() {
<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">
<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 className="text-ink-mid">|</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 className="text-ink-soft">|</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>

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 ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
: "border-transparent text-ink-soft hover:text-ink-mid"
}`}
>
{t === "claude"
@ -335,7 +335,7 @@ function SnippetBlock({
return (
<div>
<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
type="button"
onClick={onCopy}
@ -366,7 +366,7 @@ function Field({
}) {
return (
<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
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).
// Negative margin keeps the visual position the same as before
// — 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>
@ -105,7 +105,7 @@ export function Legend() {
{/* Status */}
<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">
{LEGEND_STATUSES.map((s) => (
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
@ -115,7 +115,7 @@ export function Legend() {
{/* Tiers */}
<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">
{LEGEND_TIERS.map(({ tier, label }) => (
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
@ -125,7 +125,7 @@ export function Legend() {
{/* Communication */}
<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">
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
<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) {
return (
<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>
);
}
@ -311,7 +311,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Namespace dropdown */}
<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">
<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:
</label>
<select
@ -337,7 +337,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
height="12"
viewBox="0 0 16 16"
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"
>
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
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>
@ -370,7 +370,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Toolbar */}
<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
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
: entries.length === 1
@ -446,11 +446,11 @@ function EmptyState({
// mirror it so the operator sees both signals.
return (
<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>
<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.
</p>
</div>
@ -459,11 +459,11 @@ function EmptyState({
if (query) {
return (
<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>
<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.
</p>
</div>
@ -471,11 +471,11 @@ function EmptyState({
}
return (
<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>
<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
appear here once written.
</p>
@ -558,7 +558,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Namespace tag */}
<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}
>
{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)}
</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 ? '▼' : '▶'}
</span>
</button>
@ -618,7 +618,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{entry.content}
</pre>
<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()}
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
</span>

View File

@ -421,7 +421,7 @@ function ProviderPickerModal({
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</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>
{entry.saved && (
<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">
{getKeyLabel(entry.key)}
</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>
{entry.saved && (
<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">
Deploy {orgName}
</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"}.
Review the credentials needed before import.
</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">
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-ink-mid line-through" : "text-ink"
configured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{envKey}
@ -492,7 +492,7 @@ function AnyOfEnvGroup({
>
<code
className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-ink-mid line-through" : "text-ink"
isConfigured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{m}

View File

@ -356,7 +356,7 @@ export function ProviderModelSelector({
<div>
<label
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>
<span className="sr-only"> (required)</span>
@ -382,13 +382,13 @@ export function ProviderModelSelector({
{selected?.tooltip && (
<p
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}
</p>
)}
{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(", ")}
</p>
)}
@ -397,7 +397,7 @@ export function ProviderModelSelector({
<div>
<label
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>
<span className="sr-only"> (required)</span>
@ -422,7 +422,7 @@ export function ProviderModelSelector({
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"
/>
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
{selected?.wildcard
? wildcardHelpText(selected)
: "Free-text model id. Make sure the provider can resolve it."}

View File

@ -157,7 +157,7 @@ export function PurchaseSuccessModal() {
</div>
<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
</span>
<button

View File

@ -104,7 +104,7 @@ export function SearchDialog() {
>
{/* Search input */}
<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" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
@ -156,7 +156,7 @@ export function SearchDialog() {
<div className="min-w-0 flex-1">
<div className="text-sm text-ink truncate">{node.data.name}</div>
{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>
<span

View File

@ -165,12 +165,12 @@ export function SidePanel() {
</h2>
<div className="flex items-center gap-2 mt-0.5">
{node.data.role && (
<span className="text-[10px] text-ink-mid truncate">
<span className="text-[10px] text-ink-soft truncate">
{node.data.role}
</span>
)}
<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}
</span>
@ -181,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
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">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@ -296,7 +296,7 @@ export function SidePanel() {
{/* Footer — workspace ID */}
<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}
</span>
</div>

View File

@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-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
aria-hidden="true"
@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
</span>
Org Templates
{orgs.length > 0 && (
<span className="text-ink-mid normal-case tracking-normal">
<span className="text-ink-soft normal-case tracking-normal">
({orgs.length})
</span>
)}
@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
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>
@ -264,14 +264,14 @@ export function OrgTemplatesSection() {
{expanded && (
<div id="org-templates-body" className="space-y-2">
{loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-mid">
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
<Spinner size="sm" />
Loading
</div>
)}
{!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>
</div>
)}
@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
</span>
</div>
{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}
</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="px-4 pt-14 pb-3 border-b border-line/60">
<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 className="flex-1 overflow-y-auto p-3 space-y-2">
@ -509,14 +509,14 @@ export function TemplatePalette() {
<OrgTemplatesSection />
{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 />
Loading
</div>
)}
{!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/
</div>
)}
@ -549,7 +549,7 @@ export function TemplatePalette() {
</div>
{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}
</p>
)}
@ -562,7 +562,7 @@ export function TemplatePalette() {
</span>
))}
{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>
)}
@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
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
</button>

View File

@ -124,7 +124,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</a>
. Click agree to continue.
</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).
</p>
</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 " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid")
: "text-ink-soft hover:text-ink-mid")
}
>
<svg

View File

@ -9,7 +9,6 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { showToast } from "@/components/Toaster";
import { ThemeToggle } from "@/components/ThemeToggle";
import { statusDotClass } from "@/lib/design-tokens";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
export function Toolbar() {
const nodes = useCanvasStore((s) => s.nodes);
@ -34,7 +33,6 @@ export function Toolbar() {
const [restartingAll, setRestartingAll] = useState(false);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const helpRef = useRef<HTMLDivElement>(null);
// 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 (
<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"
@ -317,7 +292,7 @@ export function Toolbar() {
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen}
aria-label="Open shortcuts and tips"
aria-label="Open quick help"
title="Help — shortcuts & quick start"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@ -327,44 +302,25 @@ export function Toolbar() {
</button>
{helpOpen && (
<div
role="dialog"
aria-label="Shortcuts and tips"
aria-modal="false"
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
<button
type="button"
onClick={() => setHelpOpen(false)}
aria-label="Close help dialog"
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
>
Close
</button>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." />
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." />
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
</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>
@ -384,11 +340,6 @@ export function Toolbar() {
onConfirm={restartAll}
onCancel={() => setRestartConfirmOpen(false)}
/>
<KeyboardShortcutsDialog
open={shortcutsOpen}
onClose={() => setShortcutsOpen(false)}
/>
</div>
);
}

View File

@ -1,6 +1,6 @@
"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 { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
@ -191,23 +191,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle
type="target"
position={Position.Top}
tabIndex={0}
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"
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"
/>
<div className="relative px-3.5 py-2.5">
@ -374,23 +358,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle
type="source"
position={Position.Bottom}
tabIndex={0}
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"
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"
/>
</div>
</>

View File

@ -55,7 +55,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
</h4>
{!loading && metrics && (
<span
className="text-[10px] text-ink-mid font-mono"
className="text-[10px] text-ink-soft font-mono"
data-testid="usage-period"
>
{formatPeriod(metrics.period_start, metrics.period_end)}
@ -131,7 +131,7 @@ function StatRow({
}) {
return (
<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>
</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,376 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ContextMenu component.
*
* Covers: null guard, node header (name + status), outside-click close,
* Escape close, arrow-key navigation, conditional menu items by status,
* danger items, dividers, rAF position clamping.
*/
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 { ContextMenu } from "../ContextMenu";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
patch: apiPatch,
get: vi.fn(),
},
}));
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
contextMenu: null as {
x: number;
y: number;
nodeId: string;
nodeData: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
collapsed?: boolean;
};
} | null,
closeContextMenu: vi.fn(),
updateNodeData: vi.fn(),
selectNode: vi.fn(),
setPanelTab: vi.fn(),
nestNode: vi.fn().mockResolvedValue(undefined as void),
setPendingDelete: vi.fn(),
setCollapsed: vi.fn(),
arrangeChildren: vi.fn(),
nodes: [] as Array<{
id: string;
data: { parentId?: string | null };
}>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function openMenu(overrides?: Partial<NonNullable<typeof mockStoreState.contextMenu>>) {
mockStoreState.contextMenu = {
x: 100,
y: 200,
nodeId: "n1",
nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" },
...overrides,
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("ContextMenu — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("renders nothing when contextMenu is null", () => {
mockStoreState.contextMenu = null;
render(<ContextMenu />);
expect(screen.queryByRole("menu")).toBeNull();
});
it("renders the menu when contextMenu is set", () => {
openMenu();
render(<ContextMenu />);
expect(screen.getByRole("menu")).toBeTruthy();
});
it("has aria-label describing the node name", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice");
});
it("shows the node name in the header", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.getByText("Bob")).toBeTruthy();
});
it("shows the node status in the header", () => {
openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByText("failed")).toBeTruthy();
});
});
describe("ContextMenu — close", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("closes when clicking outside the menu", () => {
openMenu();
render(<ContextMenu />);
fireEvent.mouseDown(document.body);
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Escape is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Tab is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Tab" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
});
describe("ContextMenu — menu items", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("shows Chat and Terminal only for online nodes", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
});
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
it("shows Pause for online nodes (not paused)", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy();
});
it("shows Resume for paused nodes (not Pause)", () => {
openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy();
});
it("shows Extract from Team only for child nodes", () => {
openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy();
});
it("hides Extract from Team for root nodes", () => {
openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull();
});
it("shows team items only when node has children", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy();
});
it("hides team items when node has no children", () => {
openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [];
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull();
});
it("shows Collapse Team when collapsed, Expand Team when expanded", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy();
});
it("Delete item has danger styling class", () => {
openMenu();
render(<ContextMenu />);
const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/);
});
it("renders role=separator for dividers", () => {
openMenu();
render(<ContextMenu />);
expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
});
});
describe("ContextMenu — keyboard navigation", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("ArrowDown moves focus to next enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
// First tab goes to Details (first non-disabled item)
fireEvent.keyDown(menu, { key: "ArrowDown" });
const buttons = screen.getAllByRole("menuitem");
const focusedIdx = buttons.findIndex((b) => document.activeElement === b);
expect(focusedIdx).toBeGreaterThanOrEqual(0);
});
it("ArrowUp moves focus to previous enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const beforeFocused = document.activeElement;
fireEvent.keyDown(menu, { key: "ArrowUp" });
// Focus should have moved
expect(document.activeElement).toBeTruthy();
});
});
describe("ContextMenu — item actions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("Details selects node and opens details tab", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /details/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
});
it("Chat selects node and opens chat tab", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /chat/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat");
});
it("Delete calls setPendingDelete without closing immediately", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /delete/i }));
expect(mockStoreState.setPendingDelete).toHaveBeenCalled();
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("Pause calls the pause API and updates node status optimistically", async () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
});
it("Resume calls the resume API", async () => {
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
});
});

View File

@ -1,156 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ConversationTraceModal's extractMessageText helper.
*
* Covers: MCP simple task format, request params.message.parts extraction,
* response result.parts extraction, result.root.text extraction, plain string
* result, null input, malformed input, empty strings.
*/
import { describe, expect, it } from "vitest";
import { extractMessageText } from "../ConversationTraceModal";
describe("extractMessageText — MCP simple task format", () => {
it("extracts text from body.task field", () => {
const body = { task: "Deploy the agent to production" };
expect(extractMessageText(body)).toBe("Deploy the agent to production");
});
it("returns empty string when body is null", () => {
expect(extractMessageText(null)).toBe("");
});
it("returns empty string when body is undefined", () => {
expect(extractMessageText(undefined as unknown as null)).toBe("");
});
});
describe("extractMessageText — request params.message format", () => {
it("extracts text from params.message.parts[].text", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello world" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello world");
});
it("joins multiple parts with newlines", () => {
const body = {
params: {
message: {
parts: [
{ text: "First part" },
{ text: "Second part" },
{ text: "Third part" },
],
},
},
};
expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part");
});
it("ignores parts without text field", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello\nWorld");
});
it("returns empty string when params.message is absent", () => {
const body = { params: {} };
expect(extractMessageText(body)).toBe("");
});
});
describe("extractMessageText — response result format", () => {
it("extracts text from result.parts[].text", () => {
const body = {
result: {
parts: [{ text: "Agent response" }],
},
};
expect(extractMessageText(body)).toBe("Agent response");
});
it("extracts text from result.parts[].root.text", () => {
const body = {
result: {
parts: [{ root: { text: "Root response text" } }],
},
};
expect(extractMessageText(body)).toBe("Root response text");
});
it("prefers parts[].text over parts[].root.text", () => {
const body = {
result: {
parts: [
{ text: "Direct text" },
{ root: { text: "Root text" } },
],
},
};
// Both are non-empty strings, so the first one wins (filter picks the first)
// The implementation: rText from rParts[0].text = "Direct text"
expect(extractMessageText(body)).toBe("Direct text");
});
});
describe("extractMessageText — plain string result", () => {
it("returns body.result when it is a plain string", () => {
const body = { result: "Simple string response" };
expect(extractMessageText(body)).toBe("Simple string response");
});
});
describe("extractMessageText — priority order", () => {
it("prefers task format over params format", () => {
const body = {
task: "Task text",
params: { message: { parts: [{ text: "Params text" }] } },
};
// Implementation: checks task first, returns if non-empty
expect(extractMessageText(body)).toBe("Task text");
});
it("prefers params format over result format", () => {
const body = {
params: { message: { parts: [{ text: "Params text" }] } },
result: { parts: [{ text: "Result text" }] },
};
// Implementation: checks params.message.parts first (after task)
expect(extractMessageText(body)).toBe("Params text");
});
});
describe("extractMessageText — error resilience", () => {
it("returns empty string on malformed input", () => {
expect(extractMessageText({})).toBe("");
expect(extractMessageText({ params: null })).toBe("");
expect(extractMessageText({ result: null })).toBe("");
});
it("returns empty string when all fields are absent", () => {
expect(extractMessageText({ random: "field" })).toBe("");
});
it("handles missing parts array gracefully", () => {
const body = { params: { message: {} } };
expect(extractMessageText(body)).toBe("");
});
it("handles parts with undefined text gracefully", () => {
const body = {
result: {
parts: [{ text: undefined }, { text: "valid" }],
},
};
expect(extractMessageText(body)).toBe("valid");
});
});

View File

@ -1,170 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for KeyValueField component.
*
* Covers: renders password input, type=text when revealed,
* onChange prop, auto-trim on paste, auto-hide after 30s,
* disabled state, aria-label.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KeyValueField } from "../ui/KeyValueField";
const AUTO_HIDE_MS = 30_000;
describe("KeyValueField — render", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders a password input by default", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
const input = container.querySelector("input");
expect(input).toBeTruthy();
expect(input!.getAttribute("type")).toBe("password");
});
it("uses the provided aria-label", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
});
});
describe("KeyValueField — onChange", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("calls onChange when input changes", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("passes value through unchanged when no whitespace trimming needed", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
expect(onChange).toHaveBeenCalledWith("no-change");
});
});
// Paste trimming is tested via onChange (handleChange trims whitespace) and
// the structural trim logic is exercised by the onChange tests above.
// Full paste testing requires @testing-library/user-event which is not installed.
describe("KeyValueField — auto-hide timer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("auto-hides after 30 seconds when revealed", async () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
// Reveal the value
const input = document.body.querySelector("input");
fireEvent.click(document.body.querySelector("button")!);
// After reveal, input type should be text (not password)
expect(input?.getAttribute("type")).not.toBe("password");
// Advance 30 seconds
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Value should be hidden again — the input value is managed externally
// via `value` prop, so we check the input type flipped back to password
// by verifying the button was clicked twice (setRevealed toggled)
// The component's internal revealed state should be false after timer fires.
// Since we can't read internal state, we verify the behavior by checking
// the input type (it flips back to password after auto-hide).
// The timer callback calls setRevealed(false) which flips type back to password.
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
expect(typeAfter).toBe("password");
});
it("does not fire auto-hide before 30 seconds", async () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(document.body.querySelector("button")!);
// Advance 29 seconds — should NOT have hidden yet
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS - 1000); });
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
// Still revealed (type=text) after 29s
expect(typeAfter).toBe("text");
});
it("clears the timer when revealed flips back to false before timeout", () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(document.body.querySelector("button")!);
// Hide manually before the 30s auto-hide
fireEvent.click(document.body.querySelector("button")!);
// Advance full 30s — should not crash (timer already cleared)
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Still hidden (we hid it manually)
expect(document.body.querySelector("input")?.getAttribute("type")).toBe("password");
});
});

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,69 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for MissingKeysModal's providerIdForModel helper.
*
* Covers: model match, no match, empty modelId, whitespace-only modelId,
* model with no required_env, models undefined, single vs multiple env vars,
* stable sort order for env var ordering.
*/
import { describe, expect, it } from "vitest";
import { providerIdForModel } from "../MissingKeysModal";
describe("providerIdForModel — match behavior", () => {
it("returns sorted-joined env vars when model is found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY");
});
it("returns null when model is not found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("unknown-model", models)).toBeNull();
});
it("returns null when models is undefined", () => {
expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull();
});
it("returns null when modelId is empty string", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel("", models)).toBeNull();
});
it("returns null when modelId is whitespace-only", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" ", models)).toBeNull();
});
it("trims whitespace from modelId before matching", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" claude ", models)).toBe("KEY");
});
});
describe("providerIdForModel — required_env variations", () => {
it("returns null when model has no required_env", () => {
const models = [{ id: "local-model", name: "Local Model", required_env: [] }];
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("returns null when model.required_env is undefined", () => {
const models = [{ id: "local-model", name: "Local Model" }] as Array<{
id: string;
name: string;
required_env?: string[];
}>;
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("sorts and joins multiple required_env alphabetically", () => {
const models = [
{ id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] },
];
// Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY
expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY");
});
});

View File

@ -1,174 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for OnboardingWizard component.
*
* Covers: renders only when not dismissed, renders 4 steps, dismiss
* button, localStorage persistence, progress bar width, step navigation,
* auto-advance from welcomeapi-key on nodes change, aria-live region.
*/
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 { OnboardingWizard } from "../OnboardingWizard";
import { useCanvasStore } from "@/store/canvas";
const mockStoreState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
selectedNodeId: null as string | null,
panelTab: "chat" as string,
agentMessages: {} as Record<string, unknown[]>,
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string): string | null => 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 });
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
// Reset mutable store properties (mockStoreState is const, so mutate fields)
mockStoreState.nodes = [];
mockStoreState.selectedNodeId = null;
mockStoreState.panelTab = "chat";
mockStoreState.agentMessages = {};
mockStoreState.setPanelTab = vi.fn();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("OnboardingWizard — visibility", () => {
it("renders nothing when localStorage has the complete flag", () => {
localStorageMock.getItem.mockReturnValueOnce("true");
render(<OnboardingWizard />);
expect(screen.queryByRole("complementary")).toBeNull();
});
it("renders the wizard for first-time users (no localStorage flag)", () => {
localStorageMock.getItem.mockReturnValueOnce(null);
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});
describe("OnboardingWizard — steps", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("renders step 1 'Welcome to Molecule AI' on first paint", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
expect(screen.getByText("Step 1 of 4")).toBeTruthy();
});
it("renders the 'Skip guide' button", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy();
});
it("renders the progress bar", () => {
render(<OnboardingWizard />);
// Progress bar is inside a div
const bar = document.body.querySelector(".h-full.bg-gradient-to-r");
expect(bar).toBeTruthy();
// Step 1 should be 25% wide
expect(bar?.getAttribute("style")).toContain("25%");
});
it("advances to step 2 'Set your API key' when Next is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Set your API key")).toBeTruthy();
expect(screen.getByText("Step 2 of 4")).toBeTruthy();
});
it("advances to step 3 'Send your first message' when Next is clicked twice", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Send your first message")).toBeTruthy();
expect(screen.getByText("Step 3 of 4")).toBeTruthy();
});
it("shows 'Get Started' button on the last step", () => {
render(<OnboardingWizard />);
// Navigate to done step
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("You're all set!")).toBeTruthy();
expect(screen.getByRole("button", { name: "Get Started" })).toBeTruthy();
});
it("dismisses the wizard when 'Skip guide' is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(screen.queryByRole("complementary")).toBeNull();
});
it("persists the dismissed state to localStorage when dismissed", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
});
});
describe("OnboardingWizard — auto-advance", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
describe("OnboardingWizard — accessibility", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("has aria-live='polite' region for step announcements", () => {
render(<OnboardingWizard />);
const liveRegion = document.body.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeTruthy();
expect(liveRegion?.textContent).toMatch(/onboarding step 1/i);
});
it("has role=complementary with aria-label", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});

View File

@ -1,255 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for PurchaseSuccessModal component.
*
* Covers: no render when no URL params, renders with ?purchase_success=1,
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
replaceUrl("http://localhost/");
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders nothing when URL has no purchase_success param", () => {
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
// useEffect fires after mount
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders the dialog when ?purchase_success=true is present", async () => {
replaceUrl("http://localhost/?purchase_success=true");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
it("shows the item name when &item= is present", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("MyAgent")).toBeTruthy();
expect(screen.getByText("Purchase successful")).toBeTruthy();
});
it("shows 'Your new agent' when no item param is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Your new agent")).toBeTruthy();
});
it("decodes URI-encoded item names", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("strips purchase_success and item params from the URL on mount", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const url = new URL(window.location.href);
expect(url.searchParams.get("purchase_success")).toBeNull();
expect(url.searchParams.get("item")).toBeNull();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
expect(document.getElementById(labelledby!)).toBeTruthy();
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
});
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
});

View File

@ -1,64 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for RevealToggle component.
*
* Covers: renders eye icon when hidden, eye-off when revealed,
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
});
it("uses the provided aria-label", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
});
it("uses default aria-label when label prop is omitted", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle visibility");
});
it("has title 'Show value' when revealed=false", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
});
it("has title 'Hide value' when revealed=true", () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
});
});
describe("RevealToggle — interaction", () => {
it("calls onToggle when clicked", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={false} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("renders EyeIcon (eye SVG) when revealed=false", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye icon has a circle path for the eye
expect(container.innerHTML).toContain("M1 12s4-8 11-8");
});
it("renders EyeOffIcon (eye-off SVG) when revealed=true", () => {
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye-off has a diagonal line
expect(container.innerHTML).toContain("x1");
expect(container.innerHTML).toContain("y2");
});
});

View File

@ -1,351 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for SearchDialog component.
*
* Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close,
* focus management, text filtering (name/role/status), arrow-key
* navigation, Enter to select, footer count, aria attributes.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchDialog } from "../SearchDialog";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
searchOpen: false,
setSearchOpen: vi.fn((open: boolean) => {
mockStoreState.searchOpen = open;
}),
nodes: [] as Array<{
id: string;
data: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
};
}>,
selectNode: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function dispatchKeydown(key: string, meta = false, ctrl = false) {
fireEvent.keyDown(window, {
key,
metaKey: meta,
ctrlKey: ctrl,
});
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("SearchDialog — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("does not render when searchOpen is false", () => {
mockStoreState.searchOpen = false;
render(<SearchDialog />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when searchOpen is true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy();
});
});
describe("SearchDialog — keyboard shortcuts", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("opens the dialog when Cmd+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("opens the dialog when Ctrl+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", false, true);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("clears the query when Cmd+K opens the dialog", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
it("closes the dialog when Escape is pressed while open", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
dispatchKeydown("Escape");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — focus", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("focuses the input when the dialog opens", async () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.getAttribute("role")).toBe("combobox");
});
it("input has the combobox role", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
describe("SearchDialog — filtering", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("shows all workspaces when query is empty", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by name (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "alice" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.queryByText("Carol")).toBeNull();
});
it("filters workspaces by role (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "writer" } });
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by status", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "online" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("shows 'No workspaces match' when filtering returns nothing", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "xyz123" } });
expect(screen.getByText("No workspaces match")).toBeTruthy();
});
it("shows 'No workspaces yet' when canvas is empty", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [];
render(<SearchDialog />);
expect(screen.getByText("No workspaces yet")).toBeTruthy();
});
});
describe("SearchDialog — listbox navigation", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("highlights the first result when query is typed", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } });
// First result (Alice) should be highlighted
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
});
it("ArrowDown moves highlight to the next item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
it("ArrowUp moves highlight to the previous item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowUp" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[1].getAttribute("aria-selected")).toBe("false");
});
it("Enter selects the highlighted workspace", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — aria attributes", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("dialog has role=dialog and aria-modal=true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-label")).toBe("Search workspaces");
});
it("results container has role=listbox", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("each result has role=option", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getAllByRole("option").length).toBeGreaterThan(0);
});
});
describe("SearchDialog — footer", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("footer shows singular 'workspace' when count is 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByText("1 workspace")).toBeTruthy();
});
it("footer shows plural 'workspaces' when count > 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
];
render(<SearchDialog />);
expect(screen.getByText("2 workspaces")).toBeTruthy();
});
});

View File

@ -1,173 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for SettingsButton component.
*
* Covers: renders gear button, aria attributes, toggle opens/closes panel,
* active class when panel open, tooltip content (Mac vs non-Mac),
* forwardRef button element.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsButton } from "../settings/SettingsButton";
import { useSecretsStore } from "@/stores/secrets-store";
// ─── Mock Radix Tooltip ────────────────────────────────────────────────────────
vi.mock("@radix-ui/react-tooltip", () => ({
Provider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Root: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Trigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Arrow: () => null,
}));
// ─── Mock secrets store ────────────────────────────────────────────────────────
const mockSecretsState = {
isPanelOpen: false,
openPanel: vi.fn(),
closePanel: vi.fn(),
};
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
(sel: (s: typeof mockSecretsState) => unknown) => sel(mockSecretsState),
{ getState: () => mockSecretsState },
),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getMacUserAgent() {
return vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("SettingsButton — render", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("renders a button with aria-label=Settings", () => {
render(<SettingsButton />);
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("has aria-expanded=false when panel is closed", () => {
render(<SettingsButton />);
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false");
});
it("has aria-expanded=true when panel is open", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true");
});
it("renders with active class when panel is open", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
const btn = screen.getByRole("button");
expect(btn.className).toContain("settings-button--active");
});
it("does not render active class when panel is closed", () => {
render(<SettingsButton />);
const btn = screen.getByRole("button");
expect(btn.className).not.toContain("settings-button--active");
});
});
describe("SettingsButton — toggle", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("calls openPanel when panel is closed and button is clicked", () => {
render(<SettingsButton />);
fireEvent.click(screen.getByRole("button"));
expect(mockSecretsState.openPanel).toHaveBeenCalledTimes(1);
expect(mockSecretsState.closePanel).not.toHaveBeenCalled();
});
it("calls closePanel when panel is open and button is clicked", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
fireEvent.click(screen.getByRole("button"));
expect(mockSecretsState.closePanel).toHaveBeenCalledTimes(1);
expect(mockSecretsState.openPanel).not.toHaveBeenCalled();
});
});
describe("SettingsButton — tooltip", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("shows tooltip with ⌘, on Mac", () => {
getMacUserAgent();
render(<SettingsButton />);
// Advance timers to trigger Tooltip.Provider's delay (300ms)
act(() => { vi.advanceTimersByTime(300); });
// The Tooltip.Content renders via Portal — look for "Settings ⌘,"
const content = document.body.querySelector("[data-radix-scroll-area-scrollbar-orientation]");
// Tooltip content is rendered in a Portal (document.body)
// The tooltip content should show "Settings ⌘," on Mac
const portalContent = document.body.querySelector("div:last-child");
// Check if the gear icon button was rendered
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("shows tooltip with Ctrl+, on non-Mac", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
);
render(<SettingsButton />);
act(() => { vi.advanceTimersByTime(300); });
// Tooltip should say "Settings Ctrl+,"
// The gear button is rendered correctly
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
});
describe("SettingsButton — forwardRef", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("forwards the ref to the button element", () => {
const ref = React.createRef<HTMLButtonElement>();
render(<SettingsButton ref={ref} />);
expect(ref.current).toBe(screen.getByRole("button"));
});
});

View File

@ -1,58 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Spinner component.
*
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
const { container } = render(<Spinner />);
expect(container.querySelectorAll("svg").length).toBe(1);
});
});

View File

@ -1,57 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for StatusBadge component.
*
* Covers: renders all three status variants, aria-label, role=status,
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✓");
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
});
it("renders invalid status with ✗ icon", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✗");
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
});
it("renders unverified status with ○ icon", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("○");
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
});
it("has role=status on the badge element", () => {
render(<StatusBadge status="verified" />);
expect(screen.getByRole("status")).toBeTruthy();
});
it("includes the config className on the rendered element", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--valid");
});
it("includes status-badge--invalid class for invalid status", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--invalid");
});
it("includes status-badge--unverified class for unverified status", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--unverified");
});
});

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,216 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TestConnectionButton component.
*
* Covers: all 4 states (idle/testing/success/failure), button disabled
* during testing, disabled when secretValue empty, error detail display,
* auto-reset to idle after 3s (success) and 5s (failure), onResult callback.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestConnectionButton } from "../ui/TestConnectionButton";
import type { SecretGroup } from "@/types/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: mockValidateSecret,
}));
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
const toGroup = (id: string): SecretGroup => id as SecretGroup;
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("TestConnectionButton — render", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("renders 'Test connection' button in idle state", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("disables button when secretValue is empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
});
it("enables button when secretValue is non-empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
});
});
describe("TestConnectionButton — state machine", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("shows 'Testing…' while validateSecret is pending", async () => {
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// Button should show testing label and be disabled
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
});
it("shows 'Connected ✓' on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
});
it("shows 'Test failed' on validation failure", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
});
it("shows error detail when validation returns invalid with message", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Permission denied")).toBeTruthy();
});
it("shows generic error message on unexpected exception", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/timeout/i)).toBeTruthy();
});
});
describe("TestConnectionButton — auto-reset", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("resets to idle after 3 seconds on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(3000); });
await act(async () => { /* flush */ });
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("resets to idle after 5 seconds on failure", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("does not reset before 3 seconds on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(2900); });
await act(async () => { /* flush */ });
// Still showing success
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
});
});
describe("TestConnectionButton — onResult callback", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("calls onResult(true) on success", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(onResult).toHaveBeenCalledWith(true);
});
it("calls onResult(false) on failure", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: false });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(onResult).toHaveBeenCalledWith(false);
});
it("calls onResult(false) when exception is thrown", async () => {
const onResult = vi.fn();
mockValidateSecret.mockRejectedValue(new Error("network error"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(onResult).toHaveBeenCalledWith(false);
});
});

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,50 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TopBar component.
*
* Covers: renders header, logo, canvas name, "+ New Agent" button,
* SettingsButton integration, custom canvasName prop.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TopBar } from "../canvas/TopBar";
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
vi.mock("../settings/SettingsButton", () => ({
SettingsButton: vi.fn(() => <button aria-label="Settings"></button>),
}));
describe("TopBar — render", () => {
it("renders a header element", () => {
render(<TopBar />);
expect(document.body.querySelector("header")).toBeTruthy();
});
it("renders the canvas name (default)", () => {
render(<TopBar />);
expect(screen.getByText("Canvas")).toBeTruthy();
});
it("renders a custom canvas name", () => {
render(<TopBar canvasName="My Org Canvas" />);
expect(screen.getByText("My Org Canvas")).toBeTruthy();
});
it("renders the '+ New Agent' button", () => {
render(<TopBar />);
expect(screen.getByRole("button", { name: /new agent/i })).toBeTruthy();
});
it("renders the SettingsButton", () => {
render(<TopBar />);
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("has the logo span with aria-hidden", () => {
render(<TopBar />);
const logo = document.body.querySelector('[aria-hidden="true"]');
expect(logo?.textContent).toBe("☁");
});
});

View File

@ -1,77 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ValidationHint component.
*
* Covers: error state, valid state, neutral/hidden state,
* aria-live for error, icon rendering.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Invalid email address")).toBeTruthy();
});
it("includes the warning icon in error state", () => {
render(<ValidationHint error="Too short" />);
expect(screen.getByText(/⚠/)).toBeTruthy();
});
it("uses the error class on the paragraph element", () => {
render(<ValidationHint error="Bad input" />);
const el = screen.getByRole("alert");
expect(el.className).toContain("validation-hint--error");
});
it("renders error even when showValid is true", () => {
render(<ValidationHint error="Oops" showValid={true} />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText(/✓/)).toBeNull();
});
});
describe("ValidationHint — valid state", () => {
it("renders valid message when error is null and showValid is true", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText("Valid format")).toBeTruthy();
});
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
render(<ValidationHint error={null} showValid={true} />);
const el = document.body.querySelector(".validation-hint--valid");
expect(el).toBeTruthy();
});
it("renders nothing when error is null and showValid is false (default)", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is empty string", () => {
const { container } = render(<ValidationHint error="" />);
expect(container.textContent).toBe("");
});
});
describe("ValidationHint — neutral / not-yet-validated", () => {
it("renders nothing when error is null and showValid defaults to false", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is undefined", () => {
// @ts-expect-error — testing runtime behavior with undefined
const { container } = render(<ValidationHint error={undefined} />);
expect(container.textContent).toBe("");
});
});

View File

@ -1,75 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for createMessage the ChatMessage factory from types.ts.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMessage } from "../tabs/chat/types";
describe("createMessage", () => {
beforeEach(() => {
// Freeze time so timestamp is deterministic.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
// Stub crypto.randomUUID so message IDs are deterministic.
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("creates a message with the correct role", () => {
const userMsg = createMessage("user", "hello");
expect(userMsg.role).toBe("user");
const agentMsg = createMessage("agent", "hi there");
expect(agentMsg.role).toBe("agent");
const systemMsg = createMessage("system", "prompt loaded");
expect(systemMsg.role).toBe("system");
});
it("creates a message with the correct content", () => {
const msg = createMessage("user", "Deploy the agent now");
expect(msg.content).toBe("Deploy the agent now");
});
it("sets a deterministic id via crypto.randomUUID", () => {
const msg = createMessage("agent", "response");
expect(msg.id).toBe("fixed-uuid-1234");
});
it("sets a deterministic ISO timestamp", () => {
const msg = createMessage("user", "hello");
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
});
it("omits attachments field when none provided", () => {
const msg = createMessage("user", "hello");
expect(msg.attachments).toBeUndefined();
});
it("omits attachments field when empty array is provided", () => {
const msg = createMessage("agent", "result", []);
expect(msg.attachments).toBeUndefined();
});
it("includes attachments field when non-empty array is provided", () => {
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
const msg = createMessage("agent", "see attached", atts);
expect(msg.attachments).toEqual(atts);
});
it("returns a frozen object (prevents accidental mutation)", () => {
const msg = createMessage("user", "hello");
expect(Object.isFrozen(msg)).toBe(true);
});
it("returns a plain object with expected keys", () => {
const msg = createMessage("user", "hello");
expect(Object.keys(msg).sort()).toEqual(
["id", "role", "content", "timestamp"].sort()
);
});
});

View File

@ -1,104 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for getIcon the pure icon-selector from FilesTab/tree.ts.
*/
import { describe, it, expect } from "vitest";
import { getIcon } from "../tabs/FilesTab/tree";
describe("getIcon", () => {
// ─── Directories ──────────────────────────────────────────────────────────
it("returns 📁 for directories regardless of extension", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("node_modules", true)).toBe("📁");
expect(getIcon(".claude", true)).toBe("📁");
expect(getIcon("foo/bar/baz", true)).toBe("📁");
});
it("returns 📁 even for paths that look like files", () => {
expect(getIcon("foo.txt", true)).toBe("📁");
expect(getIcon("script.sh", true)).toBe("📁");
});
// ─── Files by extension ────────────────────────────────────────────────────
it("returns 📄 for .md files", () => {
expect(getIcon("README.md", false)).toBe("📄");
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
expect(getIcon("docs/guide.md", false)).toBe("📄");
});
it("returns ⚙ for .yaml and .yml files", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
expect(getIcon("values.yml", false)).toBe("⚙");
expect(getIcon("deploy.yaml", false)).toBe("⚙");
});
it("returns 🐍 for .py files", () => {
expect(getIcon("main.py", false)).toBe("🐍");
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
});
it("returns 💠 for .ts and .tsx files", () => {
expect(getIcon("index.ts", false)).toBe("💠");
expect(getIcon("Component.tsx", false)).toBe("💠");
expect(getIcon("types.d.ts", false)).toBe("💠");
});
it("returns 📜 for .js files", () => {
expect(getIcon("bundle.js", false)).toBe("📜");
expect(getIcon("src/index.js", false)).toBe("📜");
});
it("returns {} for .json files", () => {
expect(getIcon("package.json", false)).toBe("{}");
expect(getIcon("config.json", false)).toBe("{}");
});
it("returns 🌐 for .html files", () => {
expect(getIcon("index.html", false)).toBe("🌐");
expect(getIcon("templates/page.html", false)).toBe("🌐");
});
it("returns 🎨 for .css files", () => {
expect(getIcon("style.css", false)).toBe("🎨");
expect(getIcon("src/app.css", false)).toBe("🎨");
});
it("returns ▸ for .sh files", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
});
// ─── Fallback ─────────────────────────────────────────────────────────────
it("returns 📄 for unknown extensions", () => {
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("notes.txt", false)).toBe("📄");
expect(getIcon("archive.tar.gz", false)).toBe("📄");
});
it("returns 📄 for paths with no extension", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
});
// ─── Case sensitivity ──────────────────────────────────────────────────────
it("is case-insensitive for extension lookup", () => {
expect(getIcon("image.PNG", false)).toBe("📄");
expect(getIcon("data.JSON", false)).toBe("{}");
expect(getIcon("script.SH", false)).toBe("▸");
});
// ─── Nested paths ─────────────────────────────────────────────────────────
it("uses the leaf extension for nested paths", () => {
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
});
});

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 { 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
@ -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 backward in z-order
* 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() {
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);
return () => window.removeEventListener("keydown", handler);

View File

@ -109,7 +109,7 @@ export function OrgTokensTab() {
Organization API Keys
</h3>
</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
integrations, CLI tools, or AI agents that need to manage
workspaces, settings, and secrets. Each key has the same
@ -182,13 +182,13 @@ export function OrgTokensTab() {
{/* Token list */}
{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...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-ink-mid">No active keys</p>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-xs text-ink-soft">No active keys</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a key above to authenticate API calls to this organization.
</p>
</div>
@ -209,7 +209,7 @@ export function OrgTokensTab() {
{t.name}
</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>
{t.last_used_at && (
<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>
<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.
</p>
</div>
@ -129,13 +129,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{/* Token list */}
{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...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-ink-mid">No active tokens</p>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-xs text-ink-soft">No active tokens</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a token to authenticate API calls.
</p>
</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">
{t.prefix}...
</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>
{t.last_used_at && (
<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 ${
filter === f.id
? "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}
@ -153,7 +153,7 @@ export function ActivityTab({ workspaceId }: Props) {
onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
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"}
>
@ -177,7 +177,7 @@ export function ActivityTab({ workspaceId }: Props) {
</button>
</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"}
</div>
</div>
@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
{/* Activity list */}
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{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 && (
@ -196,8 +196,8 @@ export function ActivityTab({ workspaceId }: Props) {
{!loading && !error && activities.length === 0 && (
<div className="text-center py-8">
<div className="text-ink-mid text-xs">No activity recorded yet</div>
<div className="text-ink-mid text-[9px] mt-1">
<div className="text-ink-soft text-xs">No activity recorded yet</div>
<div className="text-ink-soft text-[9px] mt-1">
Activity logs appear when agents communicate or perform tasks
</div>
</div>
@ -265,16 +265,16 @@ function ActivityRow({
</span>
{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
</span>
)}
<span className="text-[8px] text-ink-mid shrink-0">
<span className="text-[8px] text-ink-soft shrink-0">
{formatTime(entry.created_at)}
</span>
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
{expanded ? "▼" : "▶"}
</span>
</div>
@ -296,7 +296,7 @@ function ActivityRow({
{resolveName(entry.source_id)}
</span>
)}
<span className="text-[9px] text-ink-mid"></span>
<span className="text-[9px] text-ink-soft"></span>
{entry.target_id && (
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
{resolveName(entry.target_id)}
@ -338,7 +338,7 @@ function ActivityRow({
{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}
</div>
</div>
@ -386,7 +386,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
}
return (
<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">
{text.slice(0, 2000)}
</div>
@ -429,7 +429,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
return (
<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">
{text.slice(0, 2000)}
</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 }) {
return (
<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" : ""}`}>
{value}
</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> }) {
return (
<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">
{JSON.stringify(data, null, 2)}
</pre>

View File

@ -158,7 +158,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Usage stats */}
{loading ? (
<p className="text-xs text-ink-mid" data-testid="budget-loading">
<p className="text-xs text-ink-soft" data-testid="budget-loading">
Loading
</p>
) : fetchError ? (
@ -172,7 +172,7 @@ export function BudgetSection({ workspaceId }: Props) {
<span className="text-xs text-ink-mid">Credits used</span>
<span className="text-xs font-mono text-ink-mid">
<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">
{budget.budget_limit != null
? budget.budget_limit.toLocaleString()
@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Remaining credits */}
{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
</p>
)}
@ -227,7 +227,7 @@ export function BudgetSection({ workspaceId }: Props) {
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"
/>
<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 && (
<div

View File

@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) {
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 && (
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
<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
id={platformId}
value={formType}
@ -327,7 +327,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className="rounded border-line"
/>
<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>
))}
<button
@ -347,8 +347,8 @@ export function ChannelsTab({ workspaceId }: Props) {
)}
<div>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-mid block mb-1">
Allowed Users <span className="text-ink-mid">(optional, comma-separated)</span>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
</label>
<input
id={allowedUsersId}
@ -357,7 +357,7 @@ export function ChannelsTab({ workspaceId }: Props) {
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"
/>
<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.
</p>
</div>
@ -380,8 +380,8 @@ export function ChannelsTab({ workspaceId }: Props) {
{/* Channel list */}
{channels.length === 0 && !showForm && (
<div className="text-center py-8">
<p className="text-ink-mid text-xs">No channels connected</p>
<p className="text-ink-mid text-[10px] mt-1">
<p className="text-ink-soft text-xs">No channels connected</p>
<p className="text-ink-soft text-[10px] mt-1">
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
</p>
</div>
@ -402,7 +402,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<span className="text-xs font-medium text-ink">
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span>
<span className="text-[10px] text-ink-mid">
<span className="text-[10px] text-ink-soft">
{ch.config.chat_id || ch.config.channel_id || ""}
</span>
</div>
@ -419,7 +419,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className={`text-[10px] px-2 py-0.5 rounded transition ${
ch.enabled
? "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"}
@ -432,7 +432,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</button>
</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>Last: {relativeTime(ch.last_message_at)}</span>
{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";
return (
<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.required && <span className="text-ink-mid"> (optional)</span>}
{!field.required && <span className="text-ink-soft"> (optional)</span>}
</label>
{field.type === "textarea" ? (
<textarea
@ -499,7 +499,7 @@ function SchemaField({
)}
{renderExtras?.()}
{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>
);

View File

@ -965,7 +965,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{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 && (
<div
@ -984,7 +984,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
)}
{!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.
</div>
)}
@ -1002,7 +1002,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
scroll resting against the top of the conversation IS the
signal. */}
{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…" : " "}
</div>
)}
@ -1153,7 +1153,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{thinkingElapsed}s
</div>
{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>
{activityLog.map((line, i) => (
<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)}
</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>}
<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;
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 (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<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">
<span className="text-[9px] text-ink-mid">Raw YAML</span>
<span className="text-[9px] text-ink-soft">Raw YAML</span>
<input
type="checkbox"
checked={rawMode}
@ -677,7 +677,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<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
id={descriptionId}
value={config.description}
@ -689,7 +689,7 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<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
id={tierId}
value={config.tier}
@ -707,7 +707,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Runtime">
<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
id={runtimeId}
value={config.runtime || ""}
@ -791,7 +791,7 @@ export function ConfigTab({ workspaceId }: Props) {
// workspace_secrets MODEL_PROVIDER override.
<div className="space-y-3">
<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
type="text"
value={currentModelId}
@ -808,9 +808,9 @@ export function ConfigTab({ workspaceId }: Props) {
/>
</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
<span className="ml-1 text-ink-mid">
<span className="ml-1 text-ink-soft">
(override leave empty to auto-derive from model slug)
</span>
</label>
@ -859,7 +859,7 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
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.
Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at
@ -867,7 +867,7 @@ export function ConfigTab({ workspaceId }: Props) {
</p>
{currentModelSpec?.required_env?.length &&
!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>
Template suggests{" "}
<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")) && (
<Section title="Claude Settings" defaultOpen={false}>
<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
<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>
<select
id={effortId}
@ -910,9 +910,9 @@ export function ConfigTab({ workspaceId }: Props) {
</select>
</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)
<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>
<input
id={taskBudgetId}
@ -938,7 +938,7 @@ export function ConfigTab({ workspaceId }: Props) {
showing the misnamed list-input affordance. */}
<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.
Loaded in order at boot from the workspace config dir
(e.g. <code className="font-mono">system-prompt.md</code>,{' '}
@ -966,7 +966,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}>
<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
id={sandboxBackendId}
value={config.sandbox?.backend || "docker"}

View File

@ -242,7 +242,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{data.lastSampleError}
</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
type="button"
@ -268,7 +268,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<div key={s.id} className="flex items-start gap-2">
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
{s.description && (
<span className="text-xs text-ink-mid">{s.description}</span>
<span className="text-xs text-ink-soft">{s.description}</span>
)}
</div>
))}
@ -281,11 +281,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
{peersError ? (
<p className="text-xs text-bad">{peersError}</p>
) : 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.
</p>
) : 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">
{peers.map((p) => (
@ -297,7 +297,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
>
<StatusDot status={p.status} />
<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>
))}
</div>
@ -385,7 +385,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
const fieldId = useId();
return (
<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 })}
</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 }) {
return (
<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`}>
{value}
</span>

View File

@ -62,7 +62,7 @@ export function EventsTab({ workspaceId }: Props) {
}, [loadEvents]);
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 (
@ -88,7 +88,7 @@ export function EventsTab({ workspaceId }: Props) {
)}
{!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">
{events.map((event) => {
@ -115,10 +115,10 @@ export function EventsTab({ workspaceId }: Props) {
>
{event.event_type}
</span>
<span className="text-[9px] text-ink-mid ml-auto">
<span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)}
</span>
<span aria-hidden="true" className="text-[10px] text-ink-mid">
<span aria-hidden="true" className="text-[10px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</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">
{JSON.stringify(event.payload, null, 2)}
</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}
</div>
</div>

View File

@ -77,7 +77,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
return (
<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>
<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
re-show the setup snippets or rotate the workspace token.
</p>

View File

@ -203,7 +203,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
};
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 (
@ -304,7 +304,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
)}
{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
? "Drop to upload to root"
: root === "/configs"

View File

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

View File

@ -209,7 +209,7 @@ function TreeItem({
onContextMenu={(e) => openContextMenu(e, node)}
{...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] text-ink-mid flex-1">{node.name}</span>
<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"
}
>
{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}
</button>
))}

View File

@ -39,7 +39,7 @@ export function FilesToolbar({
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
</select>
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
<span className="text-[10px] text-ink-soft">{fileCount} files</span>
</div>
<div className="flex gap-1.5">
{root === "/configs" && (
@ -62,7 +62,7 @@ export function FilesToolbar({
</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
</button>
{root === "/configs" && (
@ -70,7 +70,7 @@ export function FilesToolbar({
Clear
</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>
</div>

View File

@ -27,7 +27,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="text-ink-mid mb-4"
className="text-ink-soft mb-4"
>
{/* Folder body */}
<path
@ -47,7 +47,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
/>
</svg>
<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{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
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) {
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 (
@ -197,7 +197,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between gap-3">
<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.
</p>
</div>
@ -230,7 +230,7 @@ export function MemoryTab({ workspaceId }: Props) {
/>
</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.
</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="min-w-0">
<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>.
</p>
</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="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>
</div>
<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>
</div>
<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>
</div>
</div>
@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between">
<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>.
</p>
</div>
@ -350,7 +350,7 @@ export function MemoryTab({ workspaceId }: Props) {
{showAdvanced ? (
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">
{entries.map((entry) => (
@ -364,11 +364,11 @@ export function MemoryTab({ workspaceId }: Props) {
<span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2">
{entry.expires_at && (
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
TTL {new Date(entry.expires_at).toLocaleString()}
</span>
)}
<span className="text-[10px] text-ink-mid">
<span className="text-[10px] text-ink-soft">
{expanded === entry.key ? "▼" : "▶"}
</span>
</div>
@ -420,7 +420,7 @@ export function MemoryTab({ workspaceId }: Props) {
</pre>
)}
<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()}
</span>
<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="min-w-0">
<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.
</p>
</div>

View File

@ -180,7 +180,7 @@ export function ScheduleTab({ workspaceId }: Props) {
};
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 (
@ -207,11 +207,11 @@ export function ScheduleTab({ workspaceId }: Props) {
placeholder="Schedule name (e.g., Daily security scan)"
value={formName}
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-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
id={cronId}
type="text"
@ -219,12 +219,12 @@ export function ScheduleTab({ workspaceId }: Props) {
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"
/>
<div className="text-[10px] text-ink-mid mt-0.5">
<div className="text-[10px] text-ink-soft mt-0.5">
{cronToHuman(formCron)}
</div>
</div>
<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
id={timezoneId}
value={formTimezone}
@ -245,14 +245,14 @@ export function ScheduleTab({ workspaceId }: Props) {
</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
id={promptId}
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder="What should the agent do on this schedule?"
rows={3}
className="w-full text-[10px] bg-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 className="flex items-center gap-2">
@ -290,7 +290,7 @@ export function ScheduleTab({ workspaceId }: Props) {
Cancel
</button>
</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 className="font-mono">{"0 9 * * *"} Daily at 9:00 AM</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="text-2xl mb-2"></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.
</div>
</div>
@ -336,16 +336,16 @@ export function ScheduleTab({ workspaceId }: Props) {
{sched.name || "Unnamed schedule"}
</span>
</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)}
{sched.timezone !== "UTC" && (
<span className="text-ink-mid"> ({sched.timezone})</span>
<span className="text-ink-soft"> ({sched.timezone})</span>
)}
</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 ? "..." : ""}
</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>Next: {relativeTime(sched.next_run_at)}</span>
<span>Runs: {sched.run_count}</span>

View File

@ -320,7 +320,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
aria-label="Plugins (none installed)"
>
<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>
</div>
<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 className="flex items-center justify-between gap-3">
<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">
{installed.length} installed
</h3>
@ -379,21 +379,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0">
<div className="flex items-center gap-2">
<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 && (
<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
</span>
)}
</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 && (
<div className="mt-1 flex flex-wrap gap-1">
{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>
))}
{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>
)}
@ -417,7 +417,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{/* Install from any source (github://, clawhub://, …) */}
<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="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">
Install from source
</div>
{sourceSchemes.length > 0 && (
@ -425,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{sourceSchemes.map((s) => (
<span
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}://
</span>
@ -444,7 +444,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
}}
placeholder="e.g. github://owner/repo#v1.0"
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
onClick={handleInstallCustom}
@ -454,12 +454,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
{installing === customSource.trim() ? "Installing..." : "Install"}
</button>
</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.
</div>
</div>
<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
the loading state so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a
@ -486,21 +486,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
</div>
{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 ? (
<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">
Couldn't load the plugin registry
</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.
</div>
</div>
) : registry.length === 0 ? (
<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">
<div className="text-[10px] text-ink-soft">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
@ -514,13 +514,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0">
<div className="flex items-center gap-2">
<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>
{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 && (
<div className="mt-1 flex flex-wrap gap-1">
{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>
)}
@ -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="flex items-center justify-between gap-3">
<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>
</div>
<div className="flex flex-wrap gap-2">
@ -564,7 +564,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
</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.
</p>
<div className="mt-3 flex flex-wrap gap-2">
@ -593,7 +593,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skills.length === 0 ? (
<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>
<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.
</p>
</div>
@ -604,7 +604,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="flex items-start justify-between gap-3">
<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>
{skill.tags.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5">
@ -626,7 +626,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skill.examples.length > 0 && (
<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">
{skill.examples.slice(0, 2).map((example, index) => (
<div
@ -666,7 +666,7 @@ function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[]
function MetaPill({ label, value }: { label: string; value: string }) {
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="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>
);

View File

@ -37,7 +37,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="text-ink-mid mb-4"
className="text-ink-soft mb-4"
>
<rect
x="10"
@ -74,7 +74,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
/>
</svg>
<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{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
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]);
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 (
@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) {
onClick={loadTraces}
// Added focus-visible ring; previous version was hover-only,
// 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
</button>
@ -75,9 +75,9 @@ export function TracesTab({ workspaceId }: Props) {
{traces.length === 0 && !error ? (
<div className="text-center py-8">
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
<p className="text-xs text-ink-mid">No traces yet</p>
<details className="mt-2 text-[10px] text-ink-mid">
<summary className="cursor-pointer text-ink-mid hover:text-ink-mid">How to enable tracing</summary>
<p className="text-xs text-ink-soft">No traces yet</p>
<details className="mt-2 text-[10px] text-ink-soft">
<summary className="cursor-pointer text-ink-soft hover:text-ink-mid">How to enable tracing</summary>
<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.
</p>
@ -108,20 +108,20 @@ export function TracesTab({ workspaceId }: Props) {
}`} />
<div className="flex-1 min-w-0">
<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 className="flex items-center gap-2 shrink-0">
{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`}
</span>
)}
{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
</span>
)}
<span aria-hidden="true" className="text-[9px] text-ink-mid">
<span aria-hidden="true" className="text-[9px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</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">
{trace.input && (
<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">
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
</pre>
@ -139,18 +139,18 @@ export function TracesTab({ workspaceId }: Props) {
)}
{trace.output && (
<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">
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
</pre>
</div>
)}
{trace.totalCost != null && (
<div className="text-[9px] text-ink-mid">
<div className="text-[9px] text-ink-soft">
Cost: ${trace.totalCost.toFixed(6)}
</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}
</div>
</div>

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