Compare commits
No commits in common. "main" and "feat/plugin-version-subscription" have entirely different histories.
main
...
feat/plugi
@ -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."
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# sop-tier-check — verify a Gitea PR satisfies the §SOP-6 approval gate.
|
|
||||||
#
|
|
||||||
# Reads the PR's tier label, walks approving reviewers, and checks each
|
|
||||||
# approver's Gitea team membership against the tier's eligible-team set.
|
|
||||||
# Marks pass only when at least one non-author approver is in an eligible
|
|
||||||
# team.
|
|
||||||
#
|
|
||||||
# Invoked from `.gitea/workflows/sop-tier-check.yml`. The workflow sets
|
|
||||||
# the env vars below; this script does no IO outside of stdout/stderr +
|
|
||||||
# the Gitea API.
|
|
||||||
#
|
|
||||||
# Required env:
|
|
||||||
# GITEA_TOKEN — bot PAT with read:organization,read:user,
|
|
||||||
# read:issue,read:repository scopes
|
|
||||||
# GITEA_HOST — e.g. git.moleculesai.app
|
|
||||||
# REPO — owner/name (from github.repository)
|
|
||||||
# PR_NUMBER — int (from github.event.pull_request.number)
|
|
||||||
# PR_AUTHOR — login (from github.event.pull_request.user.login)
|
|
||||||
#
|
|
||||||
# Optional:
|
|
||||||
# SOP_DEBUG=1 — print per-API-call diagnostic lines (HTTP codes,
|
|
||||||
# raw response bodies). Default: off.
|
|
||||||
#
|
|
||||||
# Stale-status caveat: Gitea Actions does not always re-fire workflows
|
|
||||||
# on `labeled` / `pull_request_review:submitted` events. If the
|
|
||||||
# sop-tier-check status is stale (e.g. red after labels/approvals were
|
|
||||||
# added), push an empty commit to the PR branch to force a synchronize
|
|
||||||
# event, OR re-request reviews. Tracked: internal#46.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
debug() {
|
|
||||||
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
|
||||||
echo " [debug] $*" >&2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate env
|
|
||||||
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
|
||||||
: "${GITEA_HOST:?GITEA_HOST required}"
|
|
||||||
: "${REPO:?REPO required (owner/name)}"
|
|
||||||
: "${PR_NUMBER:?PR_NUMBER required}"
|
|
||||||
: "${PR_AUTHOR:?PR_AUTHOR required}"
|
|
||||||
|
|
||||||
OWNER="${REPO%%/*}"
|
|
||||||
NAME="${REPO##*/}"
|
|
||||||
API="https://${GITEA_HOST}/api/v1"
|
|
||||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
|
||||||
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
|
||||||
|
|
||||||
# Sanity: token resolves to a user
|
|
||||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
|
|
||||||
if [ -z "$WHOAMI" ]; then
|
|
||||||
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "::notice::token resolves to user: $WHOAMI"
|
|
||||||
|
|
||||||
# 1. Read tier label
|
|
||||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
|
|
||||||
TIER=""
|
|
||||||
for L in $LABELS; do
|
|
||||||
case "$L" in
|
|
||||||
tier:low|tier:medium|tier:high)
|
|
||||||
if [ -n "$TIER" ]; then
|
|
||||||
echo "::error::Multiple tier labels: $TIER + $L. Apply exactly one."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
TIER="$L"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [ -z "$TIER" ]; then
|
|
||||||
echo "::error::PR has no tier:low|tier:medium|tier:high label. Apply one before merge."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
debug "tier=$TIER"
|
|
||||||
|
|
||||||
# 2. Tier → eligible teams
|
|
||||||
case "$TIER" in
|
|
||||||
tier:low) ELIGIBLE="engineers managers ceo" ;;
|
|
||||||
tier:medium) ELIGIBLE="managers ceo" ;;
|
|
||||||
tier:high) ELIGIBLE="ceo" ;;
|
|
||||||
esac
|
|
||||||
debug "eligible_teams=$ELIGIBLE"
|
|
||||||
|
|
||||||
# Resolve team-name → team-id once. /orgs/{org}/teams/{slug}/... endpoints
|
|
||||||
# don't exist on Gitea 1.22; we have to use /teams/{id}.
|
|
||||||
ORG_TEAMS_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
|
|
||||||
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
|
||||||
"${API}/orgs/${OWNER}/teams")
|
|
||||||
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
|
||||||
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
|
||||||
echo " [debug] teams-list body (first 300 chars):" >&2
|
|
||||||
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
|
|
||||||
fi
|
|
||||||
if [ "$HTTP_CODE" != "200" ]; then
|
|
||||||
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope. Add a SOP_TIER_CHECK_TOKEN secret with read:organization scope at the org level."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
declare -A TEAM_ID
|
|
||||||
for T in $ELIGIBLE; do
|
|
||||||
ID=$(jq -r --arg t "$T" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
|
|
||||||
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
|
|
||||||
VISIBLE=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
|
|
||||||
echo "::error::Team \"$T\" not found in org $OWNER. Teams visible: $VISIBLE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
TEAM_ID[$T]="$ID"
|
|
||||||
debug "team-id: $T → $ID"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 3. Read approving reviewers
|
|
||||||
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
|
||||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
|
||||||
if [ -z "$APPROVERS" ]; then
|
|
||||||
echo "::error::No approving reviews. Tier $TIER requires approval from {$ELIGIBLE} (non-author)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
|
|
||||||
|
|
||||||
# 4. For each approver: check non-author + team membership (by id)
|
|
||||||
OK=""
|
|
||||||
for U in $APPROVERS; do
|
|
||||||
if [ "$U" = "$PR_AUTHOR" ]; then
|
|
||||||
debug "skip self-review by $U"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
for T in $ELIGIBLE; do
|
|
||||||
ID="${TEAM_ID[$T]}"
|
|
||||||
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
|
||||||
"${API}/teams/${ID}/members/${U}")
|
|
||||||
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
|
|
||||||
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
|
||||||
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
|
||||||
OK="yes"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -n "$OK" ] && break
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$OK" ]; then
|
|
||||||
echo "::error::Tier $TIER requires approval from a non-author member of {$ELIGIBLE}. Got approvers: $APPROVERS — none of them satisfied team membership. Set SOP_DEBUG=1 to see per-probe HTTP codes."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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."
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
# sop-tier-check — canonical Gitea Actions workflow for §SOP-6 enforcement.
|
|
||||||
#
|
|
||||||
# Logic lives in `.gitea/scripts/sop-tier-check.sh` (extracted 2026-05-09
|
|
||||||
# from the previous inline-bash version). The script is the single source
|
|
||||||
# of truth; this workflow file just sets env + invokes it.
|
|
||||||
#
|
|
||||||
# Copy BOTH files (`.gitea/workflows/sop-tier-check.yml` +
|
|
||||||
# `.gitea/scripts/sop-tier-check.sh`) into any repo that wants the
|
|
||||||
# §SOP-6 PR gate enforced. Pair with branch protection on the protected
|
|
||||||
# branch:
|
|
||||||
# required_status_checks: ["sop-tier-check / tier-check (pull_request)"]
|
|
||||||
# required_approving_reviews: 1
|
|
||||||
# approving_review_teams: ["ceo", "managers", "engineers"]
|
|
||||||
#
|
|
||||||
# Tier → eligible-team mapping (mirror of dev-sop §SOP-6):
|
|
||||||
# tier:low → engineers, managers, ceo
|
|
||||||
# tier:medium → managers, ceo
|
|
||||||
# tier:high → ceo
|
|
||||||
#
|
|
||||||
# Force-merge: Owners-team override remains available out-of-band via
|
|
||||||
# the Gitea merge API; force-merge writes `incident.force_merge` to
|
|
||||||
# `structure_events` per §Persistent structured logging gate (Phase 3).
|
|
||||||
#
|
|
||||||
# Set `SOP_DEBUG: '1'` in the env block to enable per-API-call diagnostic
|
|
||||||
# lines — useful when diagnosing token-scope or team-id-resolution
|
|
||||||
# issues. Default off.
|
|
||||||
|
|
||||||
name: sop-tier-check
|
|
||||||
|
|
||||||
# SECURITY: triggers MUST use `pull_request_target`, not `pull_request`.
|
|
||||||
# `pull_request_target` loads the workflow definition from the BASE
|
|
||||||
# branch (i.e. `main`), not the PR's HEAD. With `pull_request`, anyone
|
|
||||||
# with write access to a feature branch could rewrite this file in
|
|
||||||
# their PR to dump SOP_TIER_CHECK_TOKEN (org-read scope) to logs and
|
|
||||||
# exfiltrate it. Verified 2026-05-09 against Gitea 1.22.6 —
|
|
||||||
# `pull_request_target` (added in Gitea 1.21 via go-gitea/gitea#25229)
|
|
||||||
# is the documented mitigation.
|
|
||||||
#
|
|
||||||
# This workflow does NOT call `actions/checkout` of PR HEAD code, so no
|
|
||||||
# untrusted code is ever executed in the runner — we only HTTP-call the
|
|
||||||
# Gitea API. If a future change adds a checkout step, it MUST pin to
|
|
||||||
# `${{ github.event.pull_request.base.sha }}` (NOT `head.sha`) to keep
|
|
||||||
# the trust boundary.
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted, dismissed, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tier-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
steps:
|
|
||||||
- name: Check out base branch (for the script)
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
# Pin to base.sha — pull_request_target's protection only
|
|
||||||
# works if we never check out PR HEAD. Same SHA the workflow
|
|
||||||
# itself was loaded from.
|
|
||||||
ref: ${{ github.event.pull_request.base.sha }}
|
|
||||||
- name: Verify tier label + reviewer team membership
|
|
||||||
env:
|
|
||||||
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
|
|
||||||
# sop-tier-bot PAT (read:organization,read:user,read:issue,
|
|
||||||
# read:repository). Stored at the org level
|
|
||||||
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
|
|
||||||
# configuration is unnecessary — every repo in the org
|
|
||||||
# picks it up automatically.
|
|
||||||
# Falls back to GITHUB_TOKEN with a clear error if missing.
|
|
||||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
|
||||||
GITEA_HOST: git.moleculesai.app
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
||||||
# Set to '1' for diagnostic per-API-call output. Off by default
|
|
||||||
# so production logs aren't noisy.
|
|
||||||
SOP_DEBUG: '0'
|
|
||||||
run: bash .gitea/scripts/sop-tier-check.sh
|
|
||||||
82
.github/workflows/canary-staging.yml
vendored
82
.github/workflows/canary-staging.yml
vendored
@ -20,19 +20,6 @@ on:
|
|||||||
# a few minutes under load — that's fine for a canary.
|
# a few minutes under load — that's fine for a canary.
|
||||||
- cron: '*/30 * * * *'
|
- cron: '*/30 * * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
keep_on_failure:
|
|
||||||
description: >-
|
|
||||||
Skip teardown when the canary fails (debugging only). The
|
|
||||||
tenant org + EC2 + CF tunnel + DNS stay alive so an operator
|
|
||||||
can SSM into the workspace EC2 and capture docker logs of the
|
|
||||||
failing claude-code container. REMEMBER to manually delete
|
|
||||||
via DELETE /cp/admin/tenants/<slug> when done so the org
|
|
||||||
doesn't accumulate cost. Only honored on workflow_dispatch;
|
|
||||||
cron runs always tear down (we don't want unattended cron
|
|
||||||
to leak resources).
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
# Serialise with the full-SaaS workflow so they don't contend for the
|
# Serialise with the full-SaaS workflow so they don't contend for the
|
||||||
# same org-create quota on staging. Different group key from
|
# same org-create quota on staging. Different group key from
|
||||||
@ -93,14 +80,6 @@ jobs:
|
|||||||
# is "Token Plan only" but cheap-per-token and fast.
|
# is "Token Plan only" but cheap-per-token and fast.
|
||||||
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
||||||
E2E_RUN_ID: "canary-${{ github.run_id }}"
|
E2E_RUN_ID: "canary-${{ github.run_id }}"
|
||||||
# Debug-only: when an operator dispatches with keep_on_failure=true,
|
|
||||||
# the canary script's E2E_KEEP_ORG=1 path skips teardown so the
|
|
||||||
# tenant org + EC2 stay alive for SSM-based log capture. Cron runs
|
|
||||||
# never set this (the input only exists on workflow_dispatch) so
|
|
||||||
# unattended cron always tears down. See molecule-core#129
|
|
||||||
# failure mode #1 — capturing the actual exception requires
|
|
||||||
# docker logs from the live container.
|
|
||||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_on_failure == 'true' && '1' || '0' }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@ -158,28 +137,27 @@ jobs:
|
|||||||
id: canary
|
id: canary
|
||||||
run: bash tests/e2e/test_staging_full_saas.sh
|
run: bash tests/e2e/test_staging_full_saas.sh
|
||||||
|
|
||||||
# Alerting: open a sticky issue on the FIRST failure; comment on
|
# Alerting: open an issue only after THREE consecutive failures so
|
||||||
# subsequent failures; auto-close on next green. Comment-on-existing
|
# transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
|
||||||
# de-duplicates so a single open issue accumulates the streak —
|
# the issue list. If an issue is already open, we still comment on
|
||||||
# ops sees one issue with N comments rather than N issues.
|
# every failure so ops sees the streak. Auto-close on next green.
|
||||||
#
|
#
|
||||||
# Why no consecutive-failures threshold (e.g., wait 3 runs before
|
# Threshold rationale: canary fires every 30 min, so 3 failures =
|
||||||
# filing): the prior threshold check used
|
# ~90 min of consecutive red — well past any single-run flake but
|
||||||
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does
|
# still tight enough that a real outage gets surfaced before the
|
||||||
# not expose (returns 404). On Gitea Actions the threshold call
|
# next deploy window.
|
||||||
# ALWAYS failed, breaking the entire alerting step and going days
|
|
||||||
# silent on real regressions (38h+ chronic red on 2026-05-07/08
|
|
||||||
# before this fix; tracked in molecule-core#129). Filing on first
|
|
||||||
# failure is also better UX — we want to know about the first red,
|
|
||||||
# not wait 90 min for it to "count." Real flakes get one issue +
|
|
||||||
# a quick close-on-green; persistent reds accumulate comments.
|
|
||||||
- name: Open issue on failure
|
- name: Open issue on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
env:
|
||||||
|
# Inject the workflow path explicitly — context.workflow is
|
||||||
|
# the *name*, not the file path the actions API needs.
|
||||||
|
WORKFLOW_PATH: '.github/workflows/canary-staging.yml'
|
||||||
|
CONSECUTIVE_THRESHOLD: '3'
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = '🔴 Canary failing: staging SaaS smoke';
|
const title = '🔴 Canary failing: staging SaaS smoke';
|
||||||
const runURL = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||||
|
|
||||||
// Find an existing open canary issue (stable title match).
|
// Find an existing open canary issue (stable title match).
|
||||||
// If one exists, this isn't a "first failure" — comment and exit.
|
// If one exists, this isn't a "first failure" — comment and exit.
|
||||||
@ -199,12 +177,32 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No open issue yet — file one on this first failure. The
|
// No open issue yet — check the last N-1 runs' conclusions.
|
||||||
// comment-on-existing branch above means subsequent failures
|
// We open the issue only if the last (THRESHOLD-1) runs ALSO
|
||||||
// accumulate as comments on this same issue, so we don't
|
// failed (so this is the 3rd consecutive red).
|
||||||
// spam new issues per run.
|
const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
|
||||||
|
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
workflow_id: process.env.WORKFLOW_PATH,
|
||||||
|
status: 'completed',
|
||||||
|
per_page: threshold,
|
||||||
|
// Skip the current in-progress run; it isn't 'completed' yet.
|
||||||
|
});
|
||||||
|
// listWorkflowRuns returns recent first. We need (threshold-1)
|
||||||
|
// prior failures (current run is the threshold-th).
|
||||||
|
const priorFailures = (runs.workflow_runs || [])
|
||||||
|
.slice(0, threshold - 1)
|
||||||
|
.filter(r => r.id !== context.runId)
|
||||||
|
.filter(r => r.conclusion === 'failure')
|
||||||
|
.length;
|
||||||
|
if (priorFailures < threshold - 1) {
|
||||||
|
core.info(`Below threshold: ${priorFailures + 1}/${threshold} consecutive failures — not filing yet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
`Canary run failed at ${new Date().toISOString()}.\n\n` +
|
`Canary run failed at ${new Date().toISOString()}, ` +
|
||||||
|
`${threshold} consecutive runs red.\n\n` +
|
||||||
`Run: ${runURL}\n\n` +
|
`Run: ${runURL}\n\n` +
|
||||||
`This issue auto-closes on the next green canary run. ` +
|
`This issue auto-closes on the next green canary run. ` +
|
||||||
`Consecutive failures add a comment here rather than a new issue.`;
|
`Consecutive failures add a comment here rather than a new issue.`;
|
||||||
@ -213,7 +211,7 @@ jobs:
|
|||||||
title, body,
|
title, body,
|
||||||
labels: ['canary-staging', 'bug'],
|
labels: ['canary-staging', 'bug'],
|
||||||
});
|
});
|
||||||
core.info('Opened canary failure issue (first red)');
|
core.info(`Opened canary failure issue (${threshold} consecutive reds)`);
|
||||||
|
|
||||||
- name: Auto-close canary issue on success
|
- name: Auto-close canary issue on success
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
99
.github/workflows/check-merge-group-trigger.yml
vendored
99
.github/workflows/check-merge-group-trigger.yml
vendored
@ -14,13 +14,6 @@ name: Check merge_group trigger on required workflows
|
|||||||
# Reasoning for staging-only: main has its own CI gating model (PR review),
|
# Reasoning for staging-only: main has its own CI gating model (PR review),
|
||||||
# but staging is what the merge queue runs on, so it's the trigger that
|
# but staging is what the merge queue runs on, so it's the trigger that
|
||||||
# matters.
|
# matters.
|
||||||
#
|
|
||||||
# Gitea stub: Gitea has no merge queue feature and no `merge_group:`
|
|
||||||
# event type. The linter would find no `merge_group:` triggers to verify
|
|
||||||
# (they don't exist on Gitea), so the lint is vacuously satisfied.
|
|
||||||
# Converting to a no-op stub keeps the workflow+job name stable for any
|
|
||||||
# commit-status context consumers while eliminating the `gh api` call
|
|
||||||
# that fails against Gitea's REST surface (#75 / PR-D).
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@ -32,6 +25,9 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- '.github/workflows/**.yml'
|
- '.github/workflows/**.yml'
|
||||||
- '.github/workflows/**.yaml'
|
- '.github/workflows/**.yaml'
|
||||||
|
# Self-listen on merge_group so the linter passes its own queue run.
|
||||||
|
merge_group:
|
||||||
|
types: [checks_requested]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@ -40,9 +36,88 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Gitea no-op (merge queue not applicable)
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- name: Verify merge_group trigger on required-check workflows
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Gitea Actions — merge queue not supported; no-op."
|
set -euo pipefail
|
||||||
echo "On GitHub this workflow lints that required-check workflows declare"
|
|
||||||
echo "merge_group: triggers to prevent queue deadlock. On Gitea that"
|
# Branch we care about — the one merge queue runs on.
|
||||||
echo "constraint is inapplicable — all workflows pass vacuously."
|
BRANCH=staging
|
||||||
|
|
||||||
|
# Pull the list of required status check contexts. If the branch
|
||||||
|
# has no protection or no required checks, exit clean — nothing
|
||||||
|
# to lint.
|
||||||
|
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
|
||||||
|
--jq '.contexts[]' 2>/dev/null || true)
|
||||||
|
if [ -z "$REQUIRED" ]; then
|
||||||
|
echo "No required status checks on ${BRANCH} — nothing to verify."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Required checks on ${BRANCH}:"
|
||||||
|
echo "${REQUIRED}" | sed 's/^/ - /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Build a map: workflow file -> set of job names declared in it.
|
||||||
|
# We use yq if available, otherwise grep the `name:` lines under
|
||||||
|
# `jobs:`. Stick with grep for portability — runner image always
|
||||||
|
# has it; yq isn't in the default image as of 2026-04.
|
||||||
|
declare -A workflow_jobs
|
||||||
|
shopt -s nullglob
|
||||||
|
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
|
||||||
|
[ -f "$wf" ] || continue
|
||||||
|
# Extract the workflow name (the `name:` at file root).
|
||||||
|
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
|
||||||
|
# Extract job step names from the `jobs:` block. A job step is:
|
||||||
|
# - id under `jobs:` (key with 2-space indent followed by colon)
|
||||||
|
# - the `name:` field inside that job (4-space indent)
|
||||||
|
# We collect both because required_status_checks contexts can
|
||||||
|
# match either, depending on how the workflow was authored.
|
||||||
|
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
|
||||||
|
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
|
||||||
|
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# For each required check, find the workflow that produces it.
|
||||||
|
# Then verify that workflow lists merge_group as a trigger.
|
||||||
|
FAILED=0
|
||||||
|
while IFS= read -r check; do
|
||||||
|
[ -z "$check" ] && continue
|
||||||
|
owning_wf=""
|
||||||
|
for wf in "${!workflow_jobs[@]}"; do
|
||||||
|
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
|
||||||
|
owning_wf="$wf"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$owning_wf" ]; then
|
||||||
|
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Does the workflow's trigger list include merge_group?
|
||||||
|
# Match either bare `merge_group:` line or merge_group with
|
||||||
|
# subsequent indented config (types: [checks_requested]).
|
||||||
|
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
|
||||||
|
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
|
||||||
|
else
|
||||||
|
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
|
||||||
|
echo "::error file=${owning_wf}:: merge_group:"
|
||||||
|
echo "::error file=${owning_wf}:: types: [checks_requested]"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
done <<< "$REQUIRED"
|
||||||
|
|
||||||
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "All required workflows on ${BRANCH} declare merge_group triggers."
|
||||||
|
|||||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -304,9 +304,13 @@ jobs:
|
|||||||
needs: [changes, canvas-build]
|
needs: [changes, canvas-build]
|
||||||
# Only fires on direct pushes to main (i.e. after staging→main promotion).
|
# Only fires on direct pushes to main (i.e. after staging→main promotion).
|
||||||
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
permissions:
|
||||||
|
# Required to post commit comments via the GitHub API.
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Write deploy reminder to step summary
|
- name: Post deploy reminder as commit comment
|
||||||
env:
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
COMMIT_SHA: ${{ github.sha }}
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
@ -333,13 +337,10 @@ jobs:
|
|||||||
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
|
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
|
||||||
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
|
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
|
||||||
|
|
||||||
# Gitea has no commit-comments API (no equivalent of
|
gh api \
|
||||||
# POST /repos/{owner}/{repo}/commits/{commit_sha}/comments).
|
--method POST \
|
||||||
# Write to GITHUB_STEP_SUMMARY instead — both GitHub Actions and
|
"repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \
|
||||||
# Gitea Actions render this as the workflow run's summary page,
|
--field "body=@/tmp/deploy-reminder.md"
|
||||||
# which is where operators look for post-deploy action items.
|
|
||||||
# (#75 / PR-D)
|
|
||||||
cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Python Lint & Test — required check, always runs. See platform-build
|
# Python Lint & Test — required check, always runs. See platform-build
|
||||||
# for the rationale.
|
# for the rationale.
|
||||||
|
|||||||
8
.github/workflows/e2e-api.yml
vendored
8
.github/workflows/e2e-api.yml
vendored
@ -51,7 +51,7 @@ name: E2E API Smoke Test
|
|||||||
# * Pre-pull `alpine:latest` so the platform-server's provisioner
|
# * Pre-pull `alpine:latest` so the platform-server's provisioner
|
||||||
# (`internal/handlers/container_files.go`) can stand up its
|
# (`internal/handlers/container_files.go`) can stand up its
|
||||||
# ephemeral token-write helper without a daemon.io round-trip.
|
# ephemeral token-write helper without a daemon.io round-trip.
|
||||||
# * Create `molecule-core-net` bridge network if missing so the
|
# * Create `molecule-monorepo-net` bridge network if missing so the
|
||||||
# provisioner's container.HostConfig {NetworkMode: ...} attach
|
# provisioner's container.HostConfig {NetworkMode: ...} attach
|
||||||
# succeeds.
|
# succeeds.
|
||||||
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
|
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
|
||||||
@ -163,12 +163,12 @@ jobs:
|
|||||||
# when the image is already present.
|
# when the image is already present.
|
||||||
docker pull alpine:latest >/dev/null
|
docker pull alpine:latest >/dev/null
|
||||||
# Provisioner attaches workspace containers to
|
# Provisioner attaches workspace containers to
|
||||||
# molecule-core-net (workspace-server/internal/provisioner/
|
# molecule-monorepo-net (workspace-server/internal/provisioner/
|
||||||
# provisioner.go::DefaultNetwork). The bridge already exists on
|
# provisioner.go::DefaultNetwork). The bridge already exists on
|
||||||
# the operator host's docker daemon — `network create` is
|
# the operator host's docker daemon — `network create` is
|
||||||
# idempotent via `|| true`.
|
# idempotent via `|| true`.
|
||||||
docker network create molecule-core-net >/dev/null 2>&1 || true
|
docker network create molecule-monorepo-net >/dev/null 2>&1 || true
|
||||||
echo "alpine:latest pre-pulled; molecule-core-net ensured."
|
echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
|
||||||
- name: Start Postgres (docker)
|
- name: Start Postgres (docker)
|
||||||
if: needs.detect-changes.outputs.api == 'true'
|
if: needs.detect-changes.outputs.api == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@ -34,7 +34,7 @@ name: Handlers Postgres Integration
|
|||||||
# So we sidestep `services:` entirely. The job container still uses
|
# So we sidestep `services:` entirely. The job container still uses
|
||||||
# host-net (inherited from runner config; required for cache server
|
# host-net (inherited from runner config; required for cache server
|
||||||
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
|
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
|
||||||
# postgres on the existing `molecule-core-net` bridge with a
|
# postgres on the existing `molecule-monorepo-net` bridge with a
|
||||||
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
|
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
|
||||||
# read its bridge IP via `docker inspect`. A host-net job container
|
# read its bridge IP via `docker inspect`. A host-net job container
|
||||||
# can reach a bridge-net container directly via the bridge IP (verified
|
# can reach a bridge-net container directly via the bridge IP (verified
|
||||||
@ -44,7 +44,7 @@ name: Handlers Postgres Integration
|
|||||||
# + No host-port collision; N parallel runs share the bridge cleanly
|
# + No host-port collision; N parallel runs share the bridge cleanly
|
||||||
# + `if: always()` cleanup runs even on test-step failure
|
# + `if: always()` cleanup runs even on test-step failure
|
||||||
# - One more step in the workflow (+~3 lines)
|
# - One more step in the workflow (+~3 lines)
|
||||||
# - Requires `molecule-core-net` to exist on the operator host
|
# - Requires `molecule-monorepo-net` to exist on the operator host
|
||||||
# (it does; declared in docker-compose.yml + docker-compose.infra.yml)
|
# (it does; declared in docker-compose.yml + docker-compose.infra.yml)
|
||||||
#
|
#
|
||||||
# Class B Hongming-owned CICD red sweep, 2026-05-08.
|
# Class B Hongming-owned CICD red sweep, 2026-05-08.
|
||||||
@ -96,7 +96,7 @@ jobs:
|
|||||||
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
|
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
# Bridge network already exists on the operator host (declared
|
# Bridge network already exists on the operator host (declared
|
||||||
# in docker-compose.yml + docker-compose.infra.yml).
|
# in docker-compose.yml + docker-compose.infra.yml).
|
||||||
PG_NETWORK: molecule-core-net
|
PG_NETWORK: molecule-monorepo-net
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: workspace-server
|
working-directory: workspace-server
|
||||||
|
|||||||
57
.github/workflows/harness-replays.yml
vendored
57
.github/workflows/harness-replays.yml
vendored
@ -56,40 +56,21 @@ jobs:
|
|||||||
run: ${{ steps.decide.outputs.run }}
|
run: ${{ steps.decide.outputs.run }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
run:
|
||||||
|
- 'workspace-server/**'
|
||||||
|
- 'canvas/**'
|
||||||
|
- 'tests/harness/**'
|
||||||
|
- '.github/workflows/harness-replays.yml'
|
||||||
- id: decide
|
- id: decide
|
||||||
run: |
|
run: |
|
||||||
# workflow_dispatch: always run (manual trigger)
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "debug=manual-trigger" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine the base commit to diff against.
|
|
||||||
# For pull_request: use base.sha (the merge-base with main/staging).
|
|
||||||
# For push: use github.event.before (the previous tip of the branch).
|
|
||||||
# Fallback for new branches (all-zeros SHA): run everything.
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ] && \
|
|
||||||
[ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
|
||||||
BASE="${{ github.event.pull_request.base.sha }}"
|
|
||||||
elif [ -n "${{ github.event.before }}" ] && \
|
|
||||||
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
|
|
||||||
BASE="${{ github.event.before }}"
|
|
||||||
else
|
else
|
||||||
# New branch or github.event.before unavailable — run everything.
|
echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
|
||||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "debug=new-branch-fallback" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub Actions and Gitea Actions both expose github.sha for HEAD.
|
|
||||||
DIFF=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null)
|
|
||||||
echo "debug=diff-base=$BASE diff-files=$DIFF" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
if echo "$DIFF" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.github/workflows/harness-replays\.yml$'; then
|
|
||||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ONE job that always runs. Real work is gated per-step on
|
# ONE job that always runs. Real work is gated per-step on
|
||||||
@ -110,17 +91,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
|
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
|
||||||
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
|
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
|
||||||
echo "::notice::Debug: ${{ needs.detect-changes.outputs.debug }}"
|
|
||||||
|
|
||||||
- if: needs.detect-changes.outputs.run == 'true'
|
- if: needs.detect-changes.outputs.run == 'true'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
# Log what files were detected so future failures include the diff.
|
|
||||||
- name: Log detected changes
|
|
||||||
if: needs.detect-changes.outputs.run == 'true'
|
|
||||||
run: |
|
|
||||||
echo "::notice::detect-changes debug: ${{ needs.detect-changes.outputs.debug }}"
|
|
||||||
|
|
||||||
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
|
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
|
||||||
# the plugin was dropped + Dockerfile.tenant no longer COPYs it.
|
# the plugin was dropped + Dockerfile.tenant no longer COPYs it.
|
||||||
|
|
||||||
@ -145,17 +119,6 @@ jobs:
|
|||||||
# symptom, different root cause: staging still has the in-image
|
# symptom, different root cause: staging still has the in-image
|
||||||
# clone path, hits the auth error directly).
|
# clone path, hits the auth error directly).
|
||||||
#
|
#
|
||||||
# 2026-05-08 sub-finding (#192): the clone step ALSO fails when
|
|
||||||
# any referenced workspace-template repo is private and the
|
|
||||||
# AUTO_SYNC_TOKEN bearer (devops-engineer persona) lacks read
|
|
||||||
# access. Root cause: 5 of 9 workspace-template repos
|
|
||||||
# (openclaw, codex, crewai, deepagents, gemini-cli) had been
|
|
||||||
# marked private with no team grant. Resolution: flipped them
|
|
||||||
# to public per `feedback_oss_first_repo_visibility_default`
|
|
||||||
# (the OSS surface should be public). Layer-3 (customer-private +
|
|
||||||
# marketplace third-party repos) tracked separately in
|
|
||||||
# internal#102.
|
|
||||||
#
|
|
||||||
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
|
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
|
||||||
# is the devops-engineer persona PAT, NOT the founder PAT (per
|
# is the devops-engineer persona PAT, NOT the founder PAT (per
|
||||||
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
|
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
|
||||||
|
|||||||
10
.github/workflows/publish-runtime.yml
vendored
10
.github/workflows/publish-runtime.yml
vendored
@ -1,15 +1,5 @@
|
|||||||
name: publish-runtime
|
name: publish-runtime
|
||||||
|
|
||||||
# DEPRECATED on Gitea Actions — this file is kept for reference only.
|
|
||||||
# Gitea Actions reads .gitea/workflows/, not .github/workflows/.
|
|
||||||
# The canonical version is now: .gitea/workflows/publish-runtime.yml
|
|
||||||
# That port:
|
|
||||||
# - Drops OIDC trusted publisher (Gitea has no environments/OIDC)
|
|
||||||
# - Uses PYPI_TOKEN secret instead of gh-action-pypi-publish
|
|
||||||
# - Uses ${GITHUB_REF#refs/tags/} instead of github.ref_name
|
|
||||||
# - Drops staging branch trigger (staging branch does not exist)
|
|
||||||
# - Drops merge_group trigger (Gitea has no merge queue)
|
|
||||||
#
|
|
||||||
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
|
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
|
||||||
# Monorepo workspace/ is the only source-of-truth for runtime code; this
|
# Monorepo workspace/ is the only source-of-truth for runtime code; this
|
||||||
# workflow is the bridge from monorepo edits to the PyPI artifact that
|
# workflow is the bridge from monorepo edits to the PyPI artifact that
|
||||||
|
|||||||
@ -284,7 +284,7 @@ cp .env.example .env
|
|||||||
./infra/scripts/setup.sh
|
./infra/scripts/setup.sh
|
||||||
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
|
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
|
||||||
# and Temporal (:7233 gRPC, :8233 UI) on the shared
|
# and Temporal (:7233 gRPC, :8233 UI) on the shared
|
||||||
# `molecule-core-net` Docker network. Temporal runs with
|
# `molecule-monorepo-net` Docker network. Temporal runs with
|
||||||
# no auth on localhost — dev-only; production must gate it.
|
# no auth on localhost — dev-only; production must gate it.
|
||||||
#
|
#
|
||||||
# Also populates the template/plugin registry by cloning every repo
|
# Also populates the template/plugin registry by cloning every repo
|
||||||
|
|||||||
@ -283,7 +283,7 @@ cp .env.example .env
|
|||||||
./infra/scripts/setup.sh
|
./infra/scripts/setup.sh
|
||||||
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
|
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
|
||||||
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
|
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
|
||||||
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权,
|
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
|
||||||
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
|
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
|
||||||
#
|
#
|
||||||
# 同时会根据 manifest.json 拉取所有模板/插件仓库到
|
# 同时会根据 manifest.json 拉取所有模板/插件仓库到
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -1,11 +1,7 @@
|
|||||||
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
# `npm ci` (not `install`) for lockfile-exact reproducibility.
|
RUN npm install
|
||||||
# `--include=optional` ensures the platform-specific @tailwindcss/oxide
|
|
||||||
# native binary lands — without it, postcss fails with "Cannot read
|
|
||||||
# properties of undefined (reading 'All')" at build time.
|
|
||||||
RUN npm ci --include=optional
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
|
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
|
||||||
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||||
@ -15,7 +11,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
|||||||
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
|
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|||||||
@ -17,24 +17,6 @@ import { dirname, join } from "node:path";
|
|||||||
// update one heuristic. Production is unaffected: `output: "standalone"`
|
// update one heuristic. Production is unaffected: `output: "standalone"`
|
||||||
// bakes resolved env into the build, and the marker file isn't shipped.
|
// bakes resolved env into the build, and the marker file isn't shipped.
|
||||||
loadMonorepoEnv();
|
loadMonorepoEnv();
|
||||||
// Boot-time matched-pair guard for ADMIN_TOKEN / NEXT_PUBLIC_ADMIN_TOKEN.
|
|
||||||
// When ADMIN_TOKEN is set on the workspace-server (server-side bearer
|
|
||||||
// gate, wsauth_middleware.go ~L245), the canvas MUST send the matching
|
|
||||||
// NEXT_PUBLIC_ADMIN_TOKEN as `Authorization: Bearer ...` on every API
|
|
||||||
// call. If only one is set, every workspace API call 401s silently —
|
|
||||||
// the canvas hydrates with empty data and the user sees a broken page
|
|
||||||
// with no console hint about the auth-config mismatch.
|
|
||||||
//
|
|
||||||
// Pre-fix the matched-pair contract was descriptive only (a comment in
|
|
||||||
// .env): future devs/agents could re-misconfigure with one of the two
|
|
||||||
// unset and silently 401. Closes the post-PR-#174 self-review gap.
|
|
||||||
//
|
|
||||||
// Warn-only (not exit) — production canvas Docker images bake these
|
|
||||||
// vars into the build at image-build time, and a missed pair there
|
|
||||||
// would still emit the warning at runtime via the standalone server's
|
|
||||||
// startup. Killing the process on misconfiguration would turn a
|
|
||||||
// recoverable auth issue into a hard crashloop.
|
|
||||||
checkAdminTokenPair();
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@ -75,43 +57,6 @@ function loadMonorepoEnv() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot-time matched-pair guard. Runs after .env has been loaded so the
|
|
||||||
// check sees the post-load state. The two env vars must be set or
|
|
||||||
// unset together; one-without-the-other is the silent-401 footgun.
|
|
||||||
//
|
|
||||||
// Treats empty string ("") as unset. An explicitly-empty `KEY=` in
|
|
||||||
// .env counts as set-to-empty in `process.env`, but for auth purposes
|
|
||||||
// an empty bearer token is equivalent to no token — so both
|
|
||||||
// `ADMIN_TOKEN=` and an unset ADMIN_TOKEN are equivalent relative to
|
|
||||||
// the matched-pair invariant.
|
|
||||||
//
|
|
||||||
// Returns void; side effect is the console.error warning. Kept as a
|
|
||||||
// separate function (exported) so a future test can reset env, call
|
|
||||||
// this, and assert on captured stderr.
|
|
||||||
export function checkAdminTokenPair(): void {
|
|
||||||
const serverSet = !!process.env.ADMIN_TOKEN;
|
|
||||||
const clientSet = !!process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
if (serverSet === clientSet) return;
|
|
||||||
// Distinct messages so the operator can tell which half is missing
|
|
||||||
// — the fix is symmetric (set the other one) but the diagnostic
|
|
||||||
// mentions which side is currently set so they don't have to grep.
|
|
||||||
if (serverSet && !clientSet) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
|
||||||
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
|
|
||||||
"canvas will 401 against workspace-server because the bearer header " +
|
|
||||||
"is never attached. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
|
||||||
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
|
|
||||||
"workspace-server will reject the bearer because no AdminAuth gate " +
|
|
||||||
"is configured. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMonorepoRoot(start: string): string | null {
|
function findMonorepoRoot(start: string): string | null {
|
||||||
let dir = start;
|
let dir = start;
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
|
|||||||
@ -354,7 +354,7 @@ function OrgCTA({ org }: { org: Org }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// provisioning / unknown — non-interactive
|
// provisioning / unknown — non-interactive
|
||||||
return <span className="text-sm text-ink-mid">{org.status}…</span>;
|
return <span className="text-sm text-ink-soft">{org.status}…</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ banner }: { banner?: React.ReactNode }) {
|
function EmptyState({ banner }: { banner?: React.ReactNode }) {
|
||||||
@ -420,7 +420,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
|||||||
aria-describedby="org-slug-hint"
|
aria-describedby="org-slug-hint"
|
||||||
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
||||||
/>
|
/>
|
||||||
<p id="org-slug-hint" className="mt-1 text-xs text-ink-mid">
|
<p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
|
||||||
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
|
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export default function Home() {
|
|||||||
<div className="fixed inset-0 flex items-center justify-center bg-surface">
|
<div className="fixed inset-0 flex items-center justify-center bg-surface">
|
||||||
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
|
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
<span className="text-xs text-ink-mid">Loading canvas...</span>
|
<span className="text-xs text-ink-soft">Loading canvas...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -119,11 +119,11 @@ function PlatformDownDiagnostic() {
|
|||||||
Most common cause on a dev host: one of those services stopped.
|
Most common cause on a dev host: one of those services stopped.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
|
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-ink-mid mb-2">Try first</div>
|
<div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
|
||||||
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
|
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
|
||||||
brew services start redis`}</pre>
|
brew services start redis`}</pre>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-ink-mid max-w-lg text-center">
|
<p className="text-[11px] text-ink-soft max-w-lg text-center">
|
||||||
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
|
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
|
||||||
the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment.
|
the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -55,13 +55,13 @@ export default function PricingPage() {
|
|||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-6 text-sm text-ink-mid">
|
<p className="mt-6 text-sm text-ink-soft">
|
||||||
Prices shown in USD. Flat-rate per org — no per-seat fees on any paid tier.
|
Prices shown in USD. Flat-rate per org — no per-seat fees on any paid tier.
|
||||||
Enterprise / self-hosted licensing available — contact us.
|
Enterprise / self-hosted licensing available — contact us.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-mid">
|
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
|
||||||
<p>
|
<p>
|
||||||
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
|
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
|
||||||
<a href="/legal/terms" className="hover:text-ink-mid">
|
<a href="/legal/terms" className="hover:text-ink-mid">
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<span className="text-xs text-ink-mid">Loading audit trail…</span>
|
<span className="text-xs text-ink-soft">Loading audit trail…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -145,7 +145,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
||||||
filter === f.id
|
filter === f.id
|
||||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||||
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
|
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||||
<span className="text-4xl text-ink-mid" aria-hidden="true">⊟</span>
|
<span className="text-4xl text-ink-soft" aria-hidden="true">⊟</span>
|
||||||
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
|
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
|
||||||
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||||
Delegation, decision, gate, and human-in-the-loop events will appear here.
|
Delegation, decision, gate, and human-in-the-loop events will appear here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Entry count footer */}
|
{/* Entry count footer */}
|
||||||
<p className="mt-3 text-center text-[9px] text-ink-mid">
|
<p className="mt-3 text-center text-[9px] text-ink-soft">
|
||||||
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
|
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
|
||||||
{cursor ? " · more available" : " · all loaded"}
|
{cursor ? " · more available" : " · all loaded"}
|
||||||
</p>
|
</p>
|
||||||
@ -265,7 +265,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Relative timestamp */}
|
{/* Relative timestamp */}
|
||||||
<span className="shrink-0 text-[9px] text-ink-mid">
|
<span className="shrink-0 text-[9px] text-ink-soft">
|
||||||
{formatAuditRelativeTime(entry.created_at, now)}
|
{formatAuditRelativeTime(entry.created_at, now)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export function BundleDropZone() {
|
|||||||
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||||
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
||||||
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
|
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
|
||||||
<div className="text-xs text-ink-mid mt-1">.bundle.json files only</div>
|
<div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
@ -187,23 +187,6 @@ function CanvasInner() {
|
|||||||
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
|
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
|
||||||
const { onMoveEnd } = useCanvasViewport();
|
const { onMoveEnd } = useCanvasViewport();
|
||||||
|
|
||||||
// Screen-reader announcements — read liveAnnouncement from the store and
|
|
||||||
// immediately clear it so the same announcement doesn't re-fire on
|
|
||||||
// re-render. Using a ref avoids a setState loop while keeping the
|
|
||||||
// effect reactive to new announcement strings.
|
|
||||||
const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement);
|
|
||||||
const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement);
|
|
||||||
const prevAnnouncement = useRef("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) {
|
|
||||||
prevAnnouncement.current = liveAnnouncement;
|
|
||||||
// Small delay so the DOM update lands before clearing, giving
|
|
||||||
// screen readers time to pick up the new text.
|
|
||||||
const timer = setTimeout(() => clearAnnouncement(""), 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [liveAnnouncement, clearAnnouncement]);
|
|
||||||
|
|
||||||
// Delete-confirmation lives in the store so the dialog survives ContextMenu
|
// Delete-confirmation lives in the store so the dialog survives ContextMenu
|
||||||
// unmounting — the prior local-in-ContextMenu state raced with the menu's
|
// unmounting — the prior local-in-ContextMenu state raced with the menu's
|
||||||
// outside-click handler.
|
// outside-click handler.
|
||||||
@ -343,21 +326,11 @@ function CanvasInner() {
|
|||||||
<DropTargetBadge />
|
<DropTargetBadge />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
||||||
{/* Screen-reader live region — announces workspace count on initial load and
|
{/* Screen-reader live region: announces workspace count on canvas load or change */}
|
||||||
live status updates from WebSocket events (online, offline, provisioning, etc.).
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
The liveAnnouncement text is cleared after the screen reader has had time
|
{nodes.filter((n) => !n.parentId).length === 0
|
||||||
to read it so the same message doesn't re-announce on re-render. */}
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
{liveAnnouncement || (
|
|
||||||
nodes.filter((n) => !n.parentId).length === 0
|
|
||||||
? "No workspaces on canvas"
|
? "No workspaces on canvas"
|
||||||
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`
|
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nodes.length === 0 && <EmptyState />}
|
{nodes.length === 0 && <EmptyState />}
|
||||||
|
|||||||
@ -226,7 +226,7 @@ export function CommunicationOverlay() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVisible(false)}
|
onClick={() => setVisible(false)}
|
||||||
aria-label="Close communications panel"
|
aria-label="Close communications panel"
|
||||||
className="text-ink-mid hover:text-ink-mid text-xs"
|
className="text-ink-soft hover:text-ink-mid text-xs"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">✕</span>
|
<span aria-hidden="true">✕</span>
|
||||||
</button>
|
</button>
|
||||||
@ -268,7 +268,7 @@ export function CommunicationOverlay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{c.summary && (
|
{c.summary && (
|
||||||
<div className="text-ink-mid truncate mt-0.5 pl-4">{c.summary}</div>
|
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
|
||||||
)}
|
)}
|
||||||
{c.durationMs && (
|
{c.durationMs && (
|
||||||
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
|
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
|
||||||
|
|||||||
@ -103,7 +103,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
|||||||
EC2 console output
|
EC2 console output
|
||||||
</h3>
|
</h3>
|
||||||
{workspaceName && (
|
{workspaceName && (
|
||||||
<div className="text-[11px] text-ink-mid mt-0.5 truncate max-w-[600px]">
|
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
|
||||||
{workspaceName}
|
{workspaceName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -124,7 +124,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
|||||||
|
|
||||||
<div className="flex-1 overflow-auto bg-black/80 p-4">
|
<div className="flex-1 overflow-auto bg-black/80 p-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-[12px] text-ink-mid" data-testid="console-loading">
|
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
|
||||||
Loading console output…
|
Loading console output…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -311,7 +311,7 @@ export function ContextMenu() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
|
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span>
|
<span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
<Dialog.Title className="text-sm font-semibold text-ink">
|
<Dialog.Title className="text-sm font-semibold text-ink">
|
||||||
Conversation Trace
|
Conversation Trace
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-[10px] text-ink-mid mt-0.5">
|
<p className="text-[10px] text-ink-soft mt-0.5">
|
||||||
{entries.length} events across all workspaces
|
{entries.length} events across all workspaces
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -114,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close conversation trace"
|
aria-label="Close conversation trace"
|
||||||
className="text-ink-mid hover:text-ink-mid text-lg px-2"
|
className="text-ink-soft hover:text-ink-mid text-lg px-2"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -124,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">
|
<div className="text-xs text-ink-soft text-center py-8">
|
||||||
Loading trace from all workspaces...
|
Loading trace from all workspaces...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && entries.length === 0 && (
|
{!loading && entries.length === 0 && (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">
|
<div className="text-xs text-ink-soft text-center py-8">
|
||||||
No activity found
|
No activity found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -250,7 +250,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
{/* Message content — show request and/or response */}
|
{/* Message content — show request and/or response */}
|
||||||
{requestText && (
|
{requestText && (
|
||||||
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||||
<div className="text-[8px] text-ink-mid uppercase mb-1">
|
<div className="text-[8px] text-ink-soft uppercase mb-1">
|
||||||
{isSend ? "Task" : "Request"}
|
{isSend ? "Task" : "Request"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
|||||||
@ -338,7 +338,7 @@ export function CreateWorkspaceButton() {
|
|||||||
<Dialog.Title className="text-base font-semibold text-ink mb-1">
|
<Dialog.Title className="text-base font-semibold text-ink mb-1">
|
||||||
Create Workspace
|
Create Workspace
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-xs text-ink-mid mb-5">
|
<p className="text-xs text-ink-soft mb-5">
|
||||||
Add a new workspace node to the canvas
|
Add a new workspace node to the canvas
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -376,7 +376,7 @@ export function CreateWorkspaceButton() {
|
|||||||
/>
|
/>
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="text-ink font-medium">External agent (bring your own compute)</div>
|
<div className="text-ink font-medium">External agent (bring your own compute)</div>
|
||||||
<div className="text-ink-mid mt-0.5">
|
<div className="text-ink-soft mt-0.5">
|
||||||
Skip the container. We'll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
|
Skip the container. We'll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
|
|||||||
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
|
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
|
||||||
Hermes Provider
|
Hermes Provider
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-ink-mid -mt-1">
|
<p className="text-[11px] text-ink-soft -mt-1">
|
||||||
Choose the AI provider and paste your API key. The key is
|
Choose the AI provider and paste your API key. The key is
|
||||||
stored as an encrypted workspace secret.
|
stored as an encrypted workspace secret.
|
||||||
</p>
|
</p>
|
||||||
@ -534,7 +534,7 @@ export function CreateWorkspaceButton() {
|
|||||||
(m) => <option key={m} value={m} />,
|
(m) => <option key={m} value={m} />,
|
||||||
)}
|
)}
|
||||||
</datalist>
|
</datalist>
|
||||||
<p className="text-[10px] text-ink-mid mt-1">
|
<p className="text-[10px] text-ink-soft mt-1">
|
||||||
Slug determines which provider hermes routes to at install time.
|
Slug determines which provider hermes routes to at install time.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -626,7 +626,7 @@ function InputField({
|
|||||||
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||||
/>
|
/>
|
||||||
{helper && (
|
{helper && (
|
||||||
<p className="mt-1 text-xs text-ink-mid">{helper}</p>
|
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -129,11 +129,11 @@ export function EmptyState() {
|
|||||||
T{t.tier}
|
T{t.tier}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-ink-mid line-clamp-2 leading-relaxed">
|
<p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
|
||||||
{t.description || "No description"}
|
{t.description || "No description"}
|
||||||
</p>
|
</p>
|
||||||
{t.skill_count > 0 && (
|
{t.skill_count > 0 && (
|
||||||
<p className="text-[9px] text-ink-mid mt-1.5">
|
<p className="text-[9px] text-ink-soft mt-1.5">
|
||||||
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
|
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
|
||||||
{t.model ? ` · ${t.model}` : ""}
|
{t.model ? ` · ${t.model}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@ -174,10 +174,10 @@ export function EmptyState() {
|
|||||||
<div className="mt-5 pt-4 border-t border-line/50">
|
<div className="mt-5 pt-4 border-t border-line/50">
|
||||||
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
|
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
|
||||||
<span>Drag to nest workspaces into teams</span>
|
<span>Drag to nest workspaces into teams</span>
|
||||||
<span className="text-ink-mid">|</span>
|
<span className="text-ink-soft">|</span>
|
||||||
<span>Right-click for actions</span>
|
<span>Right-click for actions</span>
|
||||||
<span className="text-ink-mid">|</span>
|
<span className="text-ink-soft">|</span>
|
||||||
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-mid font-mono">⌘K</kbd> to search</span>
|
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">⌘K</kbd> to search</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -201,7 +201,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
|||||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||||
tab === t
|
tab === t
|
||||||
? "border-accent text-ink"
|
? "border-accent text-ink"
|
||||||
: "border-transparent text-ink-mid hover:text-ink-mid"
|
: "border-transparent text-ink-soft hover:text-ink-mid"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "claude"
|
{t === "claude"
|
||||||
@ -335,7 +335,7 @@ function SnippetBlock({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between pb-1">
|
<div className="flex items-center justify-between pb-1">
|
||||||
<span className="text-xs text-ink-mid">{label}</span>
|
<span className="text-xs text-ink-soft">{label}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
@ -366,7 +366,7 @@ function Field({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-ink-mid w-36 shrink-0">{label}</span>
|
<span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
|
||||||
<code
|
<code
|
||||||
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
|
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -97,7 +97,7 @@ export function Legend() {
|
|||||||
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
|
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
|
||||||
// Negative margin keeps the visual position the same as before
|
// Negative margin keeps the visual position the same as before
|
||||||
// — only the hit area + focus ring are larger.
|
// — only the hit area + focus ring are larger.
|
||||||
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
|
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@ -105,7 +105,7 @@ export function Legend() {
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="text-[11px] text-ink-mid font-medium mb-1">Status</div>
|
<div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
{LEGEND_STATUSES.map((s) => (
|
{LEGEND_STATUSES.map((s) => (
|
||||||
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
|
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
|
||||||
@ -115,7 +115,7 @@ export function Legend() {
|
|||||||
|
|
||||||
{/* Tiers */}
|
{/* Tiers */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="text-[11px] text-ink-mid font-medium mb-1">Tier</div>
|
<div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
{LEGEND_TIERS.map(({ tier, label }) => (
|
{LEGEND_TIERS.map(({ tier, label }) => (
|
||||||
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
|
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
|
||||||
@ -125,7 +125,7 @@ export function Legend() {
|
|||||||
|
|
||||||
{/* Communication */}
|
{/* Communication */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-ink-mid font-medium mb-1">Communication</div>
|
<div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
|
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
|
||||||
<CommItem icon="↙" color="text-accent" label="A2A In" />
|
<CommItem icon="↙" color="text-accent" label="A2A In" />
|
||||||
|
|||||||
@ -288,7 +288,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
if (loading && entries.length === 0 && !error && !pluginUnavailable) {
|
if (loading && entries.length === 0 && !error && !pluginUnavailable) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<span className="text-xs text-ink-mid">Loading memories…</span>
|
<span className="text-xs text-ink-soft">Loading memories…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -311,7 +311,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
{/* Namespace dropdown */}
|
{/* Namespace dropdown */}
|
||||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
|
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-mid shrink-0">
|
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
|
||||||
Namespace:
|
Namespace:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -337,7 +337,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
height="12"
|
height="12"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
fill="none"
|
fill="none"
|
||||||
className="absolute left-2.5 text-ink-mid pointer-events-none shrink-0"
|
className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
setDebouncedQuery('');
|
setDebouncedQuery('');
|
||||||
}}
|
}}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
|
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@ -370,7 +370,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
|
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
|
||||||
<span className="text-[11px] text-ink-mid">
|
<span className="text-[11px] text-ink-soft">
|
||||||
{debouncedQuery
|
{debouncedQuery
|
||||||
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
|
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
|
||||||
: entries.length === 1
|
: entries.length === 1
|
||||||
@ -446,11 +446,11 @@ function EmptyState({
|
|||||||
// mirror it so the operator sees both signals.
|
// mirror it so the operator sees both signals.
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||||
<span className="text-4xl text-ink-mid" aria-hidden="true">
|
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||||
◇
|
◇
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
|
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
|
||||||
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||||
See banner above for the operator-side fix.
|
See banner above for the operator-side fix.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -459,11 +459,11 @@ function EmptyState({
|
|||||||
if (query) {
|
if (query) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||||
<span className="text-4xl text-ink-mid" aria-hidden="true">
|
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||||
◇
|
◇
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-medium text-ink-mid">No memories match your search</p>
|
<p className="text-sm font-medium text-ink-mid">No memories match your search</p>
|
||||||
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||||
Try a different query or clear the search.
|
Try a different query or clear the search.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -471,11 +471,11 @@ function EmptyState({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||||
<span className="text-4xl text-ink-mid" aria-hidden="true">
|
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||||
◇
|
◇
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-medium text-ink-mid">No memories yet</p>
|
<p className="text-sm font-medium text-ink-mid">No memories yet</p>
|
||||||
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||||
Agents commit memories via MCP tools (commit_memory, commit_summary). They
|
Agents commit memories via MCP tools (commit_memory, commit_summary). They
|
||||||
appear here once written.
|
appear here once written.
|
||||||
</p>
|
</p>
|
||||||
@ -558,7 +558,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
|||||||
|
|
||||||
{/* Namespace tag */}
|
{/* Namespace tag */}
|
||||||
<span
|
<span
|
||||||
className="text-[9px] shrink-0 font-mono text-ink-mid truncate max-w-[100px]"
|
className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
|
||||||
title={entry.namespace}
|
title={entry.namespace}
|
||||||
>
|
>
|
||||||
{entry.namespace}
|
{entry.namespace}
|
||||||
@ -598,10 +598,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<span className="text-[9px] text-ink-mid shrink-0">
|
<span className="text-[9px] text-ink-soft shrink-0">
|
||||||
{formatRelativeTime(entry.created_at)}
|
{formatRelativeTime(entry.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-ink-mid shrink-0" aria-hidden="true">
|
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
|
||||||
{expanded ? '▼' : '▶'}
|
{expanded ? '▼' : '▶'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -618,7 +618,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
|||||||
{entry.content}
|
{entry.content}
|
||||||
</pre>
|
</pre>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[9px] text-ink-mid">
|
<span className="text-[9px] text-ink-soft">
|
||||||
Created: {new Date(entry.created_at).toLocaleString()}
|
Created: {new Date(entry.created_at).toLocaleString()}
|
||||||
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
|
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -421,7 +421,7 @@ function ProviderPickerModal({
|
|||||||
<div className="text-[11px] text-ink-mid font-medium">
|
<div className="text-[11px] text-ink-mid font-medium">
|
||||||
{getKeyLabel(entry.key)}
|
{getKeyLabel(entry.key)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||||
</div>
|
</div>
|
||||||
{entry.saved && (
|
{entry.saved && (
|
||||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||||
@ -675,7 +675,7 @@ function AllKeysModal({
|
|||||||
<div className="text-[11px] text-ink-mid font-medium">
|
<div className="text-[11px] text-ink-mid font-medium">
|
||||||
{getKeyLabel(entry.key)}
|
{getKeyLabel(entry.key)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||||
</div>
|
</div>
|
||||||
{entry.saved && (
|
{entry.saved && (
|
||||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||||
|
|||||||
@ -247,7 +247,7 @@ export function OrgImportPreflightModal({
|
|||||||
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
|
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
|
||||||
Deploy {orgName}
|
Deploy {orgName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-0.5 text-[11px] text-ink-mid">
|
<p className="mt-0.5 text-[11px] text-ink-soft">
|
||||||
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
||||||
Review the credentials needed before import.
|
Review the credentials needed before import.
|
||||||
</p>
|
</p>
|
||||||
@ -400,7 +400,7 @@ function StrictEnvRow({
|
|||||||
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
|
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
|
||||||
<code
|
<code
|
||||||
className={`text-[11px] font-mono flex-1 ${
|
className={`text-[11px] font-mono flex-1 ${
|
||||||
configured ? "text-ink-mid line-through" : "text-ink"
|
configured ? "text-ink-soft line-through" : "text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{envKey}
|
{envKey}
|
||||||
@ -492,7 +492,7 @@ function AnyOfEnvGroup({
|
|||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
className={`text-[11px] font-mono flex-1 ${
|
className={`text-[11px] font-mono flex-1 ${
|
||||||
isConfigured ? "text-ink-mid line-through" : "text-ink"
|
isConfigured ? "text-ink-soft line-through" : "text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m}
|
{m}
|
||||||
|
|||||||
@ -356,7 +356,7 @@ export function ProviderModelSelector({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor={providerSelectId}
|
htmlFor={providerSelectId}
|
||||||
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block"
|
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||||
>
|
>
|
||||||
Provider <span aria-hidden="true" className="text-bad">*</span>
|
Provider <span aria-hidden="true" className="text-bad">*</span>
|
||||||
<span className="sr-only"> (required)</span>
|
<span className="sr-only"> (required)</span>
|
||||||
@ -382,13 +382,13 @@ export function ProviderModelSelector({
|
|||||||
{selected?.tooltip && (
|
{selected?.tooltip && (
|
||||||
<p
|
<p
|
||||||
id={`${providerSelectId}-help`}
|
id={`${providerSelectId}-help`}
|
||||||
className="text-[9px] text-ink-mid mt-1 leading-relaxed"
|
className="text-[9px] text-ink-soft mt-1 leading-relaxed"
|
||||||
>
|
>
|
||||||
{selected.tooltip}
|
{selected.tooltip}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selected && selected.envVars.length > 0 && (
|
{selected && selected.envVars.length > 0 && (
|
||||||
<p className="text-[9px] text-ink-mid mt-0.5 font-mono">
|
<p className="text-[9px] text-ink-soft mt-0.5 font-mono">
|
||||||
requires: {selected.envVars.join(", ")}
|
requires: {selected.envVars.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -397,7 +397,7 @@ export function ProviderModelSelector({
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor={modelSelectId}
|
htmlFor={modelSelectId}
|
||||||
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block"
|
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||||
>
|
>
|
||||||
Model <span aria-hidden="true" className="text-bad">*</span>
|
Model <span aria-hidden="true" className="text-bad">*</span>
|
||||||
<span className="sr-only"> (required)</span>
|
<span className="sr-only"> (required)</span>
|
||||||
@ -422,7 +422,7 @@ export function ProviderModelSelector({
|
|||||||
data-testid="model-input"
|
data-testid="model-input"
|
||||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
|
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
|
||||||
{selected?.wildcard
|
{selected?.wildcard
|
||||||
? wildcardHelpText(selected)
|
? wildcardHelpText(selected)
|
||||||
: "Free-text model id. Make sure the provider can resolve it."}
|
: "Free-text model id. Make sure the provider can resolve it."}
|
||||||
|
|||||||
@ -157,7 +157,7 @@ export function PurchaseSuccessModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
|
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
|
||||||
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-mid">
|
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
|
||||||
auto-dismiss · {AUTO_DISMISS_MS / 1000}s
|
auto-dismiss · {AUTO_DISMISS_MS / 1000}s
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export function SearchDialog() {
|
|||||||
>
|
>
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-mid" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
|
||||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -156,7 +156,7 @@ export function SearchDialog() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm text-ink truncate">{node.data.name}</div>
|
<div className="text-sm text-ink truncate">{node.data.name}</div>
|
||||||
{node.data.role && (
|
{node.data.role && (
|
||||||
<div className="text-[10px] text-ink-mid truncate">{node.data.role}</div>
|
<div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -165,12 +165,12 @@ export function SidePanel() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
{node.data.role && (
|
{node.data.role && (
|
||||||
<span className="text-[10px] text-ink-mid truncate">
|
<span className="text-[10px] text-ink-soft truncate">
|
||||||
{node.data.role}
|
{node.data.role}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
|
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
|
||||||
isOnline ? "text-good bg-emerald-950/30" : "text-ink-mid bg-surface-card/50"
|
isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
|
||||||
}`}>
|
}`}>
|
||||||
T{node.data.tier}
|
T{node.data.tier}
|
||||||
</span>
|
</span>
|
||||||
@ -181,7 +181,7 @@ export function SidePanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectNode(null)}
|
onClick={() => selectNode(null)}
|
||||||
aria-label="Close workspace panel"
|
aria-label="Close workspace panel"
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors"
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
@ -296,7 +296,7 @@ export function SidePanel() {
|
|||||||
|
|
||||||
{/* Footer — workspace ID */}
|
{/* Footer — workspace ID */}
|
||||||
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
||||||
<span className="text-[9px] font-mono text-ink-mid select-all">
|
<span className="text-[9px] font-mono text-ink-soft select-all">
|
||||||
{selectedNodeId}
|
{selectedNodeId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
|
|||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-controls="org-templates-body"
|
aria-controls="org-templates-body"
|
||||||
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
|
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
|
|||||||
</span>
|
</span>
|
||||||
Org Templates
|
Org Templates
|
||||||
{orgs.length > 0 && (
|
{orgs.length > 0 && (
|
||||||
<span className="text-ink-mid normal-case tracking-normal">
|
<span className="text-ink-soft normal-case tracking-normal">
|
||||||
({orgs.length})
|
({orgs.length})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={loadOrgs}
|
onClick={loadOrgs}
|
||||||
aria-label="Refresh org templates"
|
aria-label="Refresh org templates"
|
||||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
className="text-[10px] text-ink-soft hover:text-ink-mid"
|
||||||
>
|
>
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
@ -264,14 +264,14 @@ export function OrgTemplatesSection() {
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<div id="org-templates-body" className="space-y-2">
|
<div id="org-templates-body" className="space-y-2">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-mid">
|
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && orgs.length === 0 && (
|
{!loading && orgs.length === 0 && (
|
||||||
<div className="text-[10px] text-ink-mid">
|
<div className="text-[10px] text-ink-soft">
|
||||||
No org templates in <code>org-templates/</code>
|
No org templates in <code>org-templates/</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{o.description && (
|
{o.description && (
|
||||||
<p className="text-[10px] text-ink-mid mb-2.5 line-clamp-2 leading-relaxed">
|
<p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
|
||||||
{o.description}
|
{o.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -499,7 +499,7 @@ export function TemplatePalette() {
|
|||||||
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
||||||
<div className="px-4 pt-14 pb-3 border-b border-line/60">
|
<div className="px-4 pt-14 pb-3 border-b border-line/60">
|
||||||
<h2 className="text-sm font-semibold text-ink">Templates</h2>
|
<h2 className="text-sm font-semibold text-ink">Templates</h2>
|
||||||
<p className="text-[10px] text-ink-mid mt-0.5">Click to deploy a workspace</p>
|
<p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
@ -509,14 +509,14 @@ export function TemplatePalette() {
|
|||||||
<OrgTemplatesSection />
|
<OrgTemplatesSection />
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid text-center py-8">
|
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && templates.length === 0 && (
|
{!loading && templates.length === 0 && (
|
||||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
|
<div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
|
||||||
No templates found in<br />workspace-configs-templates/
|
No templates found in<br />workspace-configs-templates/
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -549,7 +549,7 @@ export function TemplatePalette() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{t.description && (
|
{t.description && (
|
||||||
<p className="text-[10px] text-ink-mid mb-2 line-clamp-2 leading-relaxed">
|
<p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
|
||||||
{t.description}
|
{t.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -562,7 +562,7 @@ export function TemplatePalette() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{t.skills.length > 3 && (
|
{t.skills.length > 3 && (
|
||||||
<span className="text-[8px] text-ink-mid">+{t.skills.length - 3}</span>
|
<span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -580,7 +580,7 @@ export function TemplatePalette() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadTemplates}
|
onClick={loadTemplates}
|
||||||
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
|
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
|
||||||
>
|
>
|
||||||
Refresh templates
|
Refresh templates
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
|||||||
</a>
|
</a>
|
||||||
. Click agree to continue.
|
. Click agree to continue.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-xs text-ink-mid">
|
<p className="mt-3 text-xs text-ink-soft">
|
||||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
|
|||||||
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
|
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
|
||||||
(active
|
(active
|
||||||
? "bg-surface-elevated text-ink shadow-sm"
|
? "bg-surface-elevated text-ink shadow-sm"
|
||||||
: "text-ink-mid hover:text-ink-mid")
|
: "text-ink-soft hover:text-ink-mid")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|||||||
import { showToast } from "@/components/Toaster";
|
import { showToast } from "@/components/Toaster";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { statusDotClass } from "@/lib/design-tokens";
|
import { statusDotClass } from "@/lib/design-tokens";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
|
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
const nodes = useCanvasStore((s) => s.nodes);
|
||||||
@ -34,7 +33,6 @@ export function Toolbar() {
|
|||||||
const [restartingAll, setRestartingAll] = useState(false);
|
const [restartingAll, setRestartingAll] = useState(false);
|
||||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
||||||
const helpRef = useRef<HTMLDivElement>(null);
|
const helpRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Suppress toast on the very first connect at page load; only fire on reconnects.
|
// Suppress toast on the very first connect at page load; only fire on reconnects.
|
||||||
@ -129,29 +127,6 @@ export function Toolbar() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global ? shortcut opens the shortcuts dialog (mirrors the help button).
|
|
||||||
// Skip when the user is typing in an input so ? in a text field doesn't
|
|
||||||
// steal focus. Also skip when a modal/dialog is already open.
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== "?") return;
|
|
||||||
const tag = (e.target as HTMLElement).tagName;
|
|
||||||
const inInput =
|
|
||||||
tag === "INPUT" ||
|
|
||||||
tag === "TEXTAREA" ||
|
|
||||||
tag === "SELECT" ||
|
|
||||||
(e.target as HTMLElement).isContentEditable;
|
|
||||||
if (inInput) return;
|
|
||||||
// Don't fire when a modal/dialog is already mounted (canvas modals,
|
|
||||||
// side panel, etc. use z-50 or above).
|
|
||||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setShortcutsOpen(true);
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
|
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
|
||||||
@ -346,14 +321,6 @@ export function Toolbar() {
|
|||||||
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
||||||
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||||
</div>
|
</div>
|
||||||
{/* Link to the full keyboard shortcuts dialog */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setHelpOpen(false); setShortcutsOpen(true); }}
|
|
||||||
className="mt-3 w-full text-center text-[10px] text-ink-mid hover:text-accent transition-colors focus:outline-none focus-visible:underline"
|
|
||||||
>
|
|
||||||
See all shortcuts →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -373,11 +340,6 @@ export function Toolbar() {
|
|||||||
onConfirm={restartAll}
|
onConfirm={restartAll}
|
||||||
onCancel={() => setRestartConfirmOpen(false)}
|
onCancel={() => setRestartConfirmOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KeyboardShortcutsDialog
|
|
||||||
open={shortcutsOpen}
|
|
||||||
onClose={() => setShortcutsOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, type KeyboardEvent } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||||
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
|
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
|
||||||
@ -191,23 +191,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
|||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
tabIndex={0}
|
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||||
role="button"
|
|
||||||
aria-label={`Extract ${data.name} from its parent (Enter or Space)`}
|
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Keyboard accessibility for edge anchors: pressing Enter/Space on
|
|
||||||
// the top handle extracts this node from its current parent,
|
|
||||||
// moving it to the root level. Mirrors the Figma/Excalidraw
|
|
||||||
// pattern of using the connector dot as a keyboard affordance.
|
|
||||||
if (data.parentId) {
|
|
||||||
void nestNode(id, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative px-3.5 py-2.5">
|
<div className="relative px-3.5 py-2.5">
|
||||||
@ -374,23 +358,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
|||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
tabIndex={0}
|
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||||
role="button"
|
|
||||||
aria-label={`Nest selected workspace inside ${data.name} (Enter or Space)`}
|
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Keyboard accessibility for edge anchors: pressing Enter/Space on
|
|
||||||
// the bottom handle nests the currently-selected node as a child
|
|
||||||
// of this node. Requires another node to be selected first.
|
|
||||||
const selected = selectedNodeId;
|
|
||||||
if (selected && selected !== id) {
|
|
||||||
void nestNode(selected, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
{!loading && metrics && (
|
{!loading && metrics && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] text-ink-mid font-mono"
|
className="text-[10px] text-ink-soft font-mono"
|
||||||
data-testid="usage-period"
|
data-testid="usage-period"
|
||||||
>
|
>
|
||||||
{formatPeriod(metrics.period_start, metrics.period_end)}
|
{formatPeriod(metrics.period_start, metrics.period_end)}
|
||||||
@ -131,7 +131,7 @@ function StatRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center" data-testid={testId}>
|
<div className="flex justify-between items-center" data-testid={testId}>
|
||||||
<span className="text-xs text-ink-mid">{label}</span>
|
<span className="text-xs text-ink-soft">{label}</span>
|
||||||
<span className="text-xs text-ink-mid font-mono">{value}</span>
|
<span className="text-xs text-ink-mid font-mono">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,13 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { type NodeChange, type Node } from "@xyflow/react";
|
|
||||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
|
||||||
|
|
||||||
/** Returns true if the node has any direct child in the node list. */
|
|
||||||
function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean {
|
|
||||||
return nodes.some((n) => n.data.parentId === nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||||
@ -21,9 +14,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
|||||||
* Cmd/Ctrl+] — bump selected node forward in z-order
|
* Cmd/Ctrl+] — bump selected node forward in z-order
|
||||||
* Cmd/Ctrl+[ — bump selected node backward in z-order
|
* Cmd/Ctrl+[ — bump selected node backward in z-order
|
||||||
* Z — zoom-to-team if the selected node has children
|
* Z — zoom-to-team if the selected node has children
|
||||||
* Arrow keys — move selected node 10px (50px with Shift)
|
|
||||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
|
||||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
|
||||||
*/
|
*/
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -90,76 +80,6 @@ export function useKeyboardShortcuts() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow-key node movement — Figma-style keyboard drag for keyboard users.
|
|
||||||
// 10 px per press, 50 px with Shift held. Only fires when a node
|
|
||||||
// is selected and the target isn't a form control. Skipped when a
|
|
||||||
// modifier key (Cmd/Ctrl/Alt) is held so those combos can be used
|
|
||||||
// for other shortcuts (e.g. Cmd+Arrow = resize).
|
|
||||||
if (
|
|
||||||
!inInput &&
|
|
||||||
!e.metaKey &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.altKey &&
|
|
||||||
(e.key === "ArrowUp" ||
|
|
||||||
e.key === "ArrowDown" ||
|
|
||||||
e.key === "ArrowLeft" ||
|
|
||||||
e.key === "ArrowRight")
|
|
||||||
) {
|
|
||||||
const state = useCanvasStore.getState();
|
|
||||||
const selectedId = state.selectedNodeId;
|
|
||||||
if (!selectedId) return;
|
|
||||||
// Skip when a modal/dialog is already open — dialogs own their own
|
|
||||||
// arrow-key semantics and shouldn't trigger canvas moves.
|
|
||||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const step = e.shiftKey ? 50 : 10;
|
|
||||||
let dx = 0;
|
|
||||||
let dy = 0;
|
|
||||||
if (e.key === "ArrowUp") dy = -step;
|
|
||||||
else if (e.key === "ArrowDown") dy = step;
|
|
||||||
else if (e.key === "ArrowLeft") dx = -step;
|
|
||||||
else dx = step;
|
|
||||||
state.moveNode(selectedId, dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cmd/Ctrl+Arrow — keyboard-accessible node resize.
|
|
||||||
// ↑/↓ resizes height, ←/→ resizes width.
|
|
||||||
// 10 px per press (2 px with Shift for fine control).
|
|
||||||
// Uses the same onNodesChange('dimensions') path that NodeResizer uses.
|
|
||||||
if (
|
|
||||||
!inInput &&
|
|
||||||
(e.metaKey || e.ctrlKey) &&
|
|
||||||
(e.key === "ArrowUp" ||
|
|
||||||
e.key === "ArrowDown" ||
|
|
||||||
e.key === "ArrowLeft" ||
|
|
||||||
e.key === "ArrowRight")
|
|
||||||
) {
|
|
||||||
const state = useCanvasStore.getState();
|
|
||||||
const selectedId = state.selectedNodeId;
|
|
||||||
if (!selectedId) return;
|
|
||||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const step = e.shiftKey ? 2 : 10;
|
|
||||||
const node = state.nodes.find((n) => n.id === selectedId);
|
|
||||||
if (!node) return;
|
|
||||||
const currentWidth = (node.width ?? 210) as number;
|
|
||||||
const currentHeight = (node.height ?? 110) as number;
|
|
||||||
const minWidth = hasChildren(node.id, state.nodes) ? 360 : 210;
|
|
||||||
const minHeight = hasChildren(node.id, state.nodes) ? 200 : 110;
|
|
||||||
let newWidth = currentWidth;
|
|
||||||
let newHeight = currentHeight;
|
|
||||||
if (e.key === "ArrowUp") newHeight = Math.max(minHeight, currentHeight - step);
|
|
||||||
else if (e.key === "ArrowDown") newHeight = currentHeight + step;
|
|
||||||
else if (e.key === "ArrowLeft") newWidth = Math.max(minWidth, currentWidth - step);
|
|
||||||
else newWidth = currentWidth + step;
|
|
||||||
const change: NodeChange = {
|
|
||||||
type: "dimensions",
|
|
||||||
id: selectedId,
|
|
||||||
dimensions: { width: newWidth, height: newHeight },
|
|
||||||
};
|
|
||||||
state.onNodesChange([change]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export function OrgTokensTab() {
|
|||||||
Organization API Keys
|
Organization API Keys
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
<p className="text-[10px] text-ink-soft leading-relaxed">
|
||||||
Full-admin bearer tokens for this organization. Use with external
|
Full-admin bearer tokens for this organization. Use with external
|
||||||
integrations, CLI tools, or AI agents that need to manage
|
integrations, CLI tools, or AI agents that need to manage
|
||||||
workspaces, settings, and secrets. Each key has the same
|
workspaces, settings, and secrets. Each key has the same
|
||||||
@ -182,13 +182,13 @@ export function OrgTokensTab() {
|
|||||||
|
|
||||||
{/* Token list */}
|
{/* Token list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs">
|
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
|
||||||
<Spinner /> Loading keys...
|
<Spinner /> Loading keys...
|
||||||
</div>
|
</div>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<p className="text-xs text-ink-mid">No active keys</p>
|
<p className="text-xs text-ink-soft">No active keys</p>
|
||||||
<p className="text-[10px] text-ink-mid mt-1">
|
<p className="text-[10px] text-ink-soft mt-1">
|
||||||
Create a key above to authenticate API calls to this organization.
|
Create a key above to authenticate API calls to this organization.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -209,7 +209,7 @@ export function OrgTokensTab() {
|
|||||||
{t.name}
|
{t.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="text-[9px] text-ink-mid space-x-3">
|
<div className="text-[9px] text-ink-soft space-x-3">
|
||||||
<span>Created {formatAge(t.created_at)}</span>
|
<span>Created {formatAge(t.created_at)}</span>
|
||||||
{t.last_used_at && (
|
{t.last_used_at && (
|
||||||
<span>Last used {formatAge(t.last_used_at)}</span>
|
<span>Last used {formatAge(t.last_used_at)}</span>
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
||||||
<p className="text-[10px] text-ink-mid mt-0.5">
|
<p className="text-[10px] text-ink-soft mt-0.5">
|
||||||
Bearer tokens for authenticating API calls to this workspace.
|
Bearer tokens for authenticating API calls to this workspace.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -129,13 +129,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
|||||||
|
|
||||||
{/* Token list */}
|
{/* Token list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs">
|
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
|
||||||
<Spinner /> Loading tokens...
|
<Spinner /> Loading tokens...
|
||||||
</div>
|
</div>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<p className="text-xs text-ink-mid">No active tokens</p>
|
<p className="text-xs text-ink-soft">No active tokens</p>
|
||||||
<p className="text-[10px] text-ink-mid mt-1">
|
<p className="text-[10px] text-ink-soft mt-1">
|
||||||
Create a token to authenticate API calls.
|
Create a token to authenticate API calls.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -150,7 +150,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
|||||||
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
|
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
|
||||||
{t.prefix}...
|
{t.prefix}...
|
||||||
</code>
|
</code>
|
||||||
<div className="text-[9px] text-ink-mid space-x-3">
|
<div className="text-[9px] text-ink-soft space-x-3">
|
||||||
<span>Created {formatAge(t.created_at)}</span>
|
<span>Created {formatAge(t.created_at)}</span>
|
||||||
{t.last_used_at && (
|
{t.last_used_at && (
|
||||||
<span>Last used {formatAge(t.last_used_at)}</span>
|
<span>Last used {formatAge(t.last_used_at)}</span>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
|||||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
||||||
filter === f.id
|
filter === f.id
|
||||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||||
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
|
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
|
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
|
||||||
@ -153,7 +153,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
|||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
aria-pressed={autoRefresh}
|
aria-pressed={autoRefresh}
|
||||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||||
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
|
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
|
||||||
}`}
|
}`}
|
||||||
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
||||||
>
|
>
|
||||||
@ -177,7 +177,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-[10px] text-ink-mid">
|
<div className="mt-1.5 text-[10px] text-ink-soft">
|
||||||
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
|
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
|||||||
{/* Activity list */}
|
{/* Activity list */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||||
{loading && activities.length === 0 && (
|
{loading && activities.length === 0 && (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
|
<div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -196,8 +196,8 @@ export function ActivityTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
{!loading && !error && activities.length === 0 && (
|
{!loading && !error && activities.length === 0 && (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="text-ink-mid text-xs">No activity recorded yet</div>
|
<div className="text-ink-soft text-xs">No activity recorded yet</div>
|
||||||
<div className="text-ink-mid text-[9px] mt-1">
|
<div className="text-ink-soft text-[9px] mt-1">
|
||||||
Activity logs appear when agents communicate or perform tasks
|
Activity logs appear when agents communicate or perform tasks
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -265,16 +265,16 @@ function ActivityRow({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{entry.duration_ms != null && (
|
{entry.duration_ms != null && (
|
||||||
<span className="text-[8px] text-ink-mid font-mono tabular-nums shrink-0">
|
<span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
|
||||||
{entry.duration_ms}ms
|
{entry.duration_ms}ms
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-[8px] text-ink-mid shrink-0">
|
<span className="text-[8px] text-ink-soft shrink-0">
|
||||||
{formatTime(entry.created_at)}
|
{formatTime(entry.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-[9px] text-ink-mid">
|
<span className="text-[9px] text-ink-soft">
|
||||||
{expanded ? "▼" : "▶"}
|
{expanded ? "▼" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -296,7 +296,7 @@ function ActivityRow({
|
|||||||
{resolveName(entry.source_id)}
|
{resolveName(entry.source_id)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[9px] text-ink-mid">→</span>
|
<span className="text-[9px] text-ink-soft">→</span>
|
||||||
{entry.target_id && (
|
{entry.target_id && (
|
||||||
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
|
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
|
||||||
{resolveName(entry.target_id)}
|
{resolveName(entry.target_id)}
|
||||||
@ -338,7 +338,7 @@ function ActivityRow({
|
|||||||
{entry.response_body && (
|
{entry.response_body && (
|
||||||
<JsonBlock label="Response" data={entry.response_body} />
|
<JsonBlock label="Response" data={entry.response_body} />
|
||||||
)}
|
)}
|
||||||
<div className="text-[8px] text-ink-mid font-mono select-all">
|
<div className="text-[8px] text-ink-soft font-mono select-all">
|
||||||
ID: {entry.id}
|
ID: {entry.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -386,7 +386,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||||
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||||
{text.slice(0, 2000)}
|
{text.slice(0, 2000)}
|
||||||
</div>
|
</div>
|
||||||
@ -429,7 +429,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||||
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||||
{text.slice(0, 2000)}
|
{text.slice(0, 2000)}
|
||||||
</div>
|
</div>
|
||||||
@ -440,7 +440,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
|||||||
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-[8px] text-ink-mid uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
<span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
||||||
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
|
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@ -451,7 +451,7 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
|
|||||||
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
|
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
||||||
{JSON.stringify(data, null, 2)}
|
{JSON.stringify(data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
|||||||
|
|
||||||
{/* Usage stats */}
|
{/* Usage stats */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-xs text-ink-mid" data-testid="budget-loading">
|
<p className="text-xs text-ink-soft" data-testid="budget-loading">
|
||||||
Loading…
|
Loading…
|
||||||
</p>
|
</p>
|
||||||
) : fetchError ? (
|
) : fetchError ? (
|
||||||
@ -172,7 +172,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
|||||||
<span className="text-xs text-ink-mid">Credits used</span>
|
<span className="text-xs text-ink-mid">Credits used</span>
|
||||||
<span className="text-xs font-mono text-ink-mid">
|
<span className="text-xs font-mono text-ink-mid">
|
||||||
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
|
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
|
||||||
<span className="text-ink-mid mx-1">/</span>
|
<span className="text-ink-soft mx-1">/</span>
|
||||||
<span data-testid="budget-limit-value">
|
<span data-testid="budget-limit-value">
|
||||||
{budget.budget_limit != null
|
{budget.budget_limit != null
|
||||||
? budget.budget_limit.toLocaleString()
|
? budget.budget_limit.toLocaleString()
|
||||||
@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
|||||||
|
|
||||||
{/* Remaining credits */}
|
{/* Remaining credits */}
|
||||||
{budget.budget_remaining != null && (
|
{budget.budget_remaining != null && (
|
||||||
<p className="text-[11px] text-ink-mid" data-testid="budget-remaining">
|
<p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
|
||||||
{budget.budget_remaining.toLocaleString()} credits remaining
|
{budget.budget_remaining.toLocaleString()} credits remaining
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -227,7 +227,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
|||||||
data-testid="budget-limit-input"
|
data-testid="budget-limit-input"
|
||||||
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
|
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-ink-mid">Leave blank for unlimited</p>
|
<p className="text-xs text-ink-soft">Leave blank for unlimited</p>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-ink-mid text-xs">Loading channels...</div>
|
<div className="p-4 text-ink-soft text-xs">Loading channels...</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +271,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
|
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={platformId} className="text-[10px] text-ink-mid block mb-1">Platform</label>
|
<label htmlFor={platformId} className="text-[10px] text-ink-soft block mb-1">Platform</label>
|
||||||
<select
|
<select
|
||||||
id={platformId}
|
id={platformId}
|
||||||
value={formType}
|
value={formType}
|
||||||
@ -327,7 +327,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
className="rounded border-line"
|
className="rounded border-line"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
|
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
|
||||||
<span className="text-[10px] text-ink-mid ml-auto">{chat.type} {chat.chat_id}</span>
|
<span className="text-[10px] text-ink-soft ml-auto">{chat.type} {chat.chat_id}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
@ -347,8 +347,8 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-mid block mb-1">
|
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
|
||||||
Allowed Users <span className="text-ink-mid">(optional, comma-separated)</span>
|
Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={allowedUsersId}
|
id={allowedUsersId}
|
||||||
@ -357,7 +357,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
placeholder="123456789, 987654321"
|
placeholder="123456789, 987654321"
|
||||||
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
|
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-ink-mid mt-0.5">
|
<p className="text-[11px] text-ink-soft mt-0.5">
|
||||||
Platform-specific user IDs. Leave empty to allow everyone.
|
Platform-specific user IDs. Leave empty to allow everyone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -380,8 +380,8 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
{/* Channel list */}
|
{/* Channel list */}
|
||||||
{channels.length === 0 && !showForm && (
|
{channels.length === 0 && !showForm && (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-ink-mid text-xs">No channels connected</p>
|
<p className="text-ink-soft text-xs">No channels connected</p>
|
||||||
<p className="text-ink-mid text-[10px] mt-1">
|
<p className="text-ink-soft text-[10px] mt-1">
|
||||||
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
|
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -402,7 +402,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
<span className="text-xs font-medium text-ink">
|
<span className="text-xs font-medium text-ink">
|
||||||
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
|
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-ink-mid">
|
<span className="text-[10px] text-ink-soft">
|
||||||
{ch.config.chat_id || ch.config.channel_id || ""}
|
{ch.config.chat_id || ch.config.channel_id || ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -419,7 +419,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
className={`text-[10px] px-2 py-0.5 rounded transition ${
|
className={`text-[10px] px-2 py-0.5 rounded transition ${
|
||||||
ch.enabled
|
ch.enabled
|
||||||
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
|
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
|
||||||
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
|
: "bg-surface-card/50 text-ink-soft hover:text-ink-mid"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ch.enabled ? "On" : "Off"}
|
{ch.enabled ? "On" : "Off"}
|
||||||
@ -432,7 +432,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-[10px] text-ink-mid">
|
<div className="flex items-center gap-4 text-[10px] text-ink-soft">
|
||||||
<span>{ch.message_count} messages</span>
|
<span>{ch.message_count} messages</span>
|
||||||
<span>Last: {relativeTime(ch.last_message_at)}</span>
|
<span>Last: {relativeTime(ch.last_message_at)}</span>
|
||||||
{ch.allowed_users.length > 0 && (
|
{ch.allowed_users.length > 0 && (
|
||||||
@ -474,9 +474,9 @@ function SchemaField({
|
|||||||
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
|
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={inputId} className="text-[10px] text-ink-mid block mb-1">
|
<label htmlFor={inputId} className="text-[10px] text-ink-soft block mb-1">
|
||||||
{field.label}
|
{field.label}
|
||||||
{!field.required && <span className="text-ink-mid"> (optional)</span>}
|
{!field.required && <span className="text-ink-soft"> (optional)</span>}
|
||||||
</label>
|
</label>
|
||||||
{field.type === "textarea" ? (
|
{field.type === "textarea" ? (
|
||||||
<textarea
|
<textarea
|
||||||
@ -499,7 +499,7 @@ function SchemaField({
|
|||||||
)}
|
)}
|
||||||
{renderExtras?.()}
|
{renderExtras?.()}
|
||||||
{field.help && (
|
{field.help && (
|
||||||
<p className="text-[11px] text-ink-mid mt-0.5">{field.help}</p>
|
<p className="text-[11px] text-ink-soft mt-0.5">{field.help}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -965,7 +965,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
|
<div className="text-xs text-ink-soft text-center py-4">Loading chat history...</div>
|
||||||
)}
|
)}
|
||||||
{!loading && loadError !== null && messages.length === 0 && (
|
{!loading && loadError !== null && messages.length === 0 && (
|
||||||
<div
|
<div
|
||||||
@ -984,7 +984,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && loadError === null && messages.length === 0 && (
|
{!loading && loadError === null && messages.length === 0 && (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">
|
<div className="text-xs text-ink-soft text-center py-8">
|
||||||
No messages yet. Send a message to start chatting with this agent.
|
No messages yet. Send a message to start chatting with this agent.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1002,7 +1002,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
scroll resting against the top of the conversation IS the
|
scroll resting against the top of the conversation IS the
|
||||||
signal. */}
|
signal. */}
|
||||||
{hasMore && messages.length > 0 && (
|
{hasMore && messages.length > 0 && (
|
||||||
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
|
<div ref={topRef} className="text-xs text-ink-soft text-center py-1">
|
||||||
{loadingOlder ? "Loading older messages…" : " "}
|
{loadingOlder ? "Loading older messages…" : " "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1153,7 +1153,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
{thinkingElapsed}s
|
{thinkingElapsed}s
|
||||||
</div>
|
</div>
|
||||||
{activityLog.length > 0 && (
|
{activityLog.length > 0 && (
|
||||||
<div className="mt-1.5 text-[9px] text-ink-mid space-y-0.5">
|
<div className="mt-1.5 text-[9px] text-ink-soft space-y-0.5">
|
||||||
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
|
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
|
||||||
{activityLog.map((line, i) => (
|
{activityLog.map((line, i) => (
|
||||||
<div key={line + i} className="pl-2 border-l border-line">◇ {line}</div>
|
<div key={line + i} className="pl-2 border-l border-line">◇ {line}</div>
|
||||||
|
|||||||
@ -97,7 +97,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
|||||||
{JSON.stringify(card, null, 2)}
|
{JSON.stringify(card, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-ink-mid">No agent card</div>
|
<div className="text-[10px] text-ink-soft">No agent card</div>
|
||||||
)}
|
)}
|
||||||
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
|
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
|
||||||
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
|
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
|
||||||
@ -635,16 +635,16 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
|
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-xs text-ink-mid">Loading config...</div>;
|
return <div className="p-4 text-xs text-ink-soft">Loading config...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
|
||||||
<span className="text-[10px] text-ink-mid">config.yaml</span>
|
<span className="text-[10px] text-ink-soft">config.yaml</span>
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
<span className="text-[9px] text-ink-mid">Raw YAML</span>
|
<span className="text-[9px] text-ink-soft">Raw YAML</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={rawMode}
|
checked={rawMode}
|
||||||
@ -677,7 +677,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
<Section title="General">
|
<Section title="General">
|
||||||
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
|
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={descriptionId} className="text-[10px] text-ink-mid block mb-1">Description</label>
|
<label htmlFor={descriptionId} className="text-[10px] text-ink-soft block mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id={descriptionId}
|
id={descriptionId}
|
||||||
value={config.description}
|
value={config.description}
|
||||||
@ -689,7 +689,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
|
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={tierId} className="text-[10px] text-ink-mid block mb-1">Tier</label>
|
<label htmlFor={tierId} className="text-[10px] text-ink-soft block mb-1">Tier</label>
|
||||||
<select
|
<select
|
||||||
id={tierId}
|
id={tierId}
|
||||||
value={config.tier}
|
value={config.tier}
|
||||||
@ -707,7 +707,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
<Section title="Runtime">
|
<Section title="Runtime">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label>
|
<label htmlFor={runtimeId} className="text-[10px] text-ink-soft block mb-1">Runtime</label>
|
||||||
<select
|
<select
|
||||||
id={runtimeId}
|
id={runtimeId}
|
||||||
value={config.runtime || ""}
|
value={config.runtime || ""}
|
||||||
@ -791,7 +791,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
// workspace_secrets MODEL_PROVIDER override.
|
// workspace_secrets MODEL_PROVIDER override.
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-ink-mid block mb-1">Model</label>
|
<label className="text-[10px] text-ink-soft block mb-1">Model</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentModelId}
|
value={currentModelId}
|
||||||
@ -808,9 +808,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-mid block mb-1">
|
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-soft block mb-1">
|
||||||
Provider
|
Provider
|
||||||
<span className="ml-1 text-ink-mid">
|
<span className="ml-1 text-ink-soft">
|
||||||
(override — leave empty to auto-derive from model slug)
|
(override — leave empty to auto-derive from model slug)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@ -859,7 +859,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
|
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
|
||||||
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
|
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-ink-mid mt-1">
|
<p className="text-[10px] text-ink-soft mt-1">
|
||||||
This declares which env var <em>names</em> the workspace needs.
|
This declares which env var <em>names</em> the workspace needs.
|
||||||
Set the actual values in the <strong>Secrets</strong> section
|
Set the actual values in the <strong>Secrets</strong> section
|
||||||
below — those are encrypted and mounted into the container at
|
below — those are encrypted and mounted into the container at
|
||||||
@ -867,7 +867,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
{currentModelSpec?.required_env?.length &&
|
{currentModelSpec?.required_env?.length &&
|
||||||
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
|
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
|
||||||
<div className="text-[10px] text-ink-mid mt-1 flex items-center gap-2">
|
<div className="text-[10px] text-ink-soft mt-1 flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Template suggests{" "}
|
Template suggests{" "}
|
||||||
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
|
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
|
||||||
@ -890,9 +890,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
|
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
|
||||||
<Section title="Claude Settings" defaultOpen={false}>
|
<Section title="Claude Settings" defaultOpen={false}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={effortId} className="text-[10px] text-ink-mid block mb-1">
|
<label htmlFor={effortId} className="text-[10px] text-ink-soft block mb-1">
|
||||||
Effort
|
Effort
|
||||||
<span className="ml-1 text-ink-mid">(output_config.effort — Opus 4.7+)</span>
|
<span className="ml-1 text-ink-soft">(output_config.effort — Opus 4.7+)</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={effortId}
|
id={effortId}
|
||||||
@ -910,9 +910,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-mid block mb-1">
|
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-soft block mb-1">
|
||||||
Task Budget (tokens)
|
Task Budget (tokens)
|
||||||
<span className="ml-1 text-ink-mid">(output_config.task_budget.total — 0 = unset)</span>
|
<span className="ml-1 text-ink-soft">(output_config.task_budget.total — 0 = unset)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={taskBudgetId}
|
id={taskBudgetId}
|
||||||
@ -938,7 +938,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
showing the misnamed list-input affordance. */}
|
showing the misnamed list-input affordance. */}
|
||||||
|
|
||||||
<Section title="Prompt Files" defaultOpen={false}>
|
<Section title="Prompt Files" defaultOpen={false}>
|
||||||
<p className="text-[10px] text-ink-mid px-1 pb-1">
|
<p className="text-[10px] text-ink-soft px-1 pb-1">
|
||||||
Markdown files that compose this workspace's system prompt.
|
Markdown files that compose this workspace's system prompt.
|
||||||
Loaded in order at boot from the workspace config dir
|
Loaded in order at boot from the workspace config dir
|
||||||
(e.g. <code className="font-mono">system-prompt.md</code>,{' '}
|
(e.g. <code className="font-mono">system-prompt.md</code>,{' '}
|
||||||
@ -966,7 +966,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
<Section title="Sandbox" defaultOpen={false}>
|
<Section title="Sandbox" defaultOpen={false}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-mid block mb-1">Backend</label>
|
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-soft block mb-1">Backend</label>
|
||||||
<select
|
<select
|
||||||
id={sandboxBackendId}
|
id={sandboxBackendId}
|
||||||
value={config.sandbox?.backend || "docker"}
|
value={config.sandbox?.backend || "docker"}
|
||||||
|
|||||||
@ -242,7 +242,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
|||||||
{data.lastSampleError}
|
{data.lastSampleError}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-ink-mid">No error detail recorded.</p>
|
<p className="text-xs text-ink-soft">No error detail recorded.</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -268,7 +268,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
|||||||
<div key={s.id} className="flex items-start gap-2">
|
<div key={s.id} className="flex items-start gap-2">
|
||||||
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
|
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
|
||||||
{s.description && (
|
{s.description && (
|
||||||
<span className="text-xs text-ink-mid">{s.description}</span>
|
<span className="text-xs text-ink-soft">{s.description}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -281,11 +281,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
|||||||
{peersError ? (
|
{peersError ? (
|
||||||
<p className="text-xs text-bad">{peersError}</p>
|
<p className="text-xs text-bad">{peersError}</p>
|
||||||
) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? (
|
) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? (
|
||||||
<p className="text-xs text-ink-mid">
|
<p className="text-xs text-ink-soft">
|
||||||
Peers are only discoverable while the workspace is online.
|
Peers are only discoverable while the workspace is online.
|
||||||
</p>
|
</p>
|
||||||
) : peers.length === 0 ? (
|
) : peers.length === 0 ? (
|
||||||
<p className="text-xs text-ink-mid">No reachable peers</p>
|
<p className="text-xs text-ink-soft">No reachable peers</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{peers.map((p) => (
|
{peers.map((p) => (
|
||||||
@ -297,7 +297,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
|||||||
>
|
>
|
||||||
<StatusDot status={p.status} />
|
<StatusDot status={p.status} />
|
||||||
<span className="text-xs text-ink">{p.name}</span>
|
<span className="text-xs text-ink">{p.name}</span>
|
||||||
{p.role && <span className="text-[10px] text-ink-mid">{p.role}</span>}
|
{p.role && <span className="text-[10px] text-ink-soft">{p.role}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -385,7 +385,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={fieldId} className="text-[10px] text-ink-mid block mb-0.5">{label}</label>
|
<label htmlFor={fieldId} className="text-[10px] text-ink-soft block mb-0.5">{label}</label>
|
||||||
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
|
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -394,7 +394,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-xs text-ink-mid">{label}</span>
|
<span className="text-xs text-ink-soft">{label}</span>
|
||||||
<span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
|
<span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function EventsTab({ workspaceId }: Props) {
|
|||||||
}, [loadEvents]);
|
}, [loadEvents]);
|
||||||
|
|
||||||
if (loading && events.length === 0) {
|
if (loading && events.length === 0) {
|
||||||
return <div className="p-4 text-xs text-ink-mid">Loading events...</div>;
|
return <div className="p-4 text-xs text-ink-soft">Loading events...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -88,7 +88,7 @@ export function EventsTab({ workspaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && events.length === 0 ? (
|
{!error && events.length === 0 ? (
|
||||||
<p className="text-xs text-ink-mid text-center py-4">No events yet</p>
|
<p className="text-xs text-ink-soft text-center py-4">No events yet</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
@ -115,10 +115,10 @@ export function EventsTab({ workspaceId }: Props) {
|
|||||||
>
|
>
|
||||||
{event.event_type}
|
{event.event_type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-ink-mid ml-auto">
|
<span className="text-[9px] text-ink-soft ml-auto">
|
||||||
{formatTime(event.created_at)}
|
{formatTime(event.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true" className="text-[10px] text-ink-mid">
|
<span aria-hidden="true" className="text-[10px] text-ink-soft">
|
||||||
{isOpen ? "▼" : "▶"}
|
{isOpen ? "▼" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -128,7 +128,7 @@ export function EventsTab({ workspaceId }: Props) {
|
|||||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||||
{JSON.stringify(event.payload, null, 2)}
|
{JSON.stringify(event.payload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
<div className="mt-1 text-[9px] text-ink-mid font-mono">
|
<div className="mt-1 text-[9px] text-ink-soft font-mono">
|
||||||
ID: {event.id}
|
ID: {event.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -77,7 +77,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded">
|
<div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded">
|
||||||
<h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3>
|
<h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3>
|
||||||
<p className="text-[10px] text-ink-mid mb-2">
|
<p className="text-[10px] text-ink-soft mb-2">
|
||||||
This workspace runs an external agent. Use these controls to
|
This workspace runs an external agent. Use these controls to
|
||||||
re-show the setup snippets or rotate the workspace token.
|
re-show the setup snippets or rotate the workspace token.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -203,7 +203,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-xs text-ink-mid">Loading files...</div>;
|
return <div className="p-4 text-xs text-ink-soft">Loading files...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -304,7 +304,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-[10px] text-ink-mid text-center">
|
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
|
||||||
{rootDragHover
|
{rootDragHover
|
||||||
? "Drop to upload to root"
|
? "Drop to upload to root"
|
||||||
: root === "/configs"
|
: root === "/configs"
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function FileEditor({
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl opacity-20 mb-2">📄</div>
|
<div className="text-2xl opacity-20 mb-2">📄</div>
|
||||||
<p className="text-[10px] text-ink-mid">Select a file to edit</p>
|
<p className="text-[10px] text-ink-soft">Select a file to edit</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -56,7 +56,7 @@ export function FileEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
aria-label="Download file"
|
aria-label="Download file"
|
||||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
className="text-[10px] text-ink-soft hover:text-ink-mid"
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
</button>
|
</button>
|
||||||
@ -74,7 +74,7 @@ export function FileEditor({
|
|||||||
|
|
||||||
{/* Editor area */}
|
{/* Editor area */}
|
||||||
{loadingFile ? (
|
{loadingFile ? (
|
||||||
<div className="p-4 text-xs text-ink-mid">Loading...</div>
|
<div className="p-4 text-xs text-ink-soft">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
@ -209,7 +209,7 @@ function TreeItem({
|
|||||||
onContextMenu={(e) => openContextMenu(e, node)}
|
onContextMenu={(e) => openContextMenu(e, node)}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
>
|
>
|
||||||
<span className="text-[9px] text-ink-mid w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||||
<span className="text-[10px]">📁</span>
|
<span className="text-[10px]">📁</span>
|
||||||
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
|
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -132,7 +132,7 @@ export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-mid">{item.icon}</span>}
|
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function FilesToolbar({
|
|||||||
<option value="/workspace">/workspace</option>
|
<option value="/workspace">/workspace</option>
|
||||||
<option value="/plugins">/plugins</option>
|
<option value="/plugins">/plugins</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
|
<span className="text-[10px] text-ink-soft">{fileCount} files</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{root === "/configs" && (
|
{root === "/configs" && (
|
||||||
@ -62,7 +62,7 @@ export function FilesToolbar({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Download all files">
|
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Download all files">
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
{root === "/configs" && (
|
{root === "/configs" && (
|
||||||
@ -70,7 +70,7 @@ export function FilesToolbar({
|
|||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Refresh">
|
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Refresh">
|
||||||
↻
|
↻
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
|
|||||||
viewBox="0 0 72 72"
|
viewBox="0 0 72 72"
|
||||||
fill="none"
|
fill="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="text-ink-mid mb-4"
|
className="text-ink-soft mb-4"
|
||||||
>
|
>
|
||||||
{/* Folder body */}
|
{/* Folder body */}
|
||||||
<path
|
<path
|
||||||
@ -47,7 +47,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
|
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
|
||||||
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
|
||||||
This workspace runs the{" "}
|
This workspace runs the{" "}
|
||||||
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
||||||
whose filesystem isn't owned by the platform. Use the Chat tab to
|
whose filesystem isn't owned by the platform. Use the Chat tab to
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
|
return <div className="p-4 text-xs text-ink-soft">Loading memory...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -197,7 +197,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
|
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
|
||||||
<p className="text-[10px] text-ink-mid">
|
<p className="text-[10px] text-ink-soft">
|
||||||
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -230,7 +230,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
|
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-soft">
|
||||||
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -238,7 +238,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
|
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
|
||||||
<p className="text-[10px] text-ink-mid truncate">
|
<p className="text-[10px] text-ink-soft truncate">
|
||||||
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -254,15 +254,15 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
|
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
|
<span className="uppercase tracking-[0.18em] text-ink-soft">Status</span>
|
||||||
<span className="font-medium text-good">Connected</span>
|
<span className="font-medium text-good">Connected</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
|
<span className="uppercase tracking-[0.18em] text-ink-soft">Mode</span>
|
||||||
<span className="font-medium text-ink">{awarenessStatus}</span>
|
<span className="font-medium text-ink">{awarenessStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
|
<span className="uppercase tracking-[0.18em] text-ink-soft">Workspace</span>
|
||||||
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
|
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
|
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
|
||||||
<p className="text-[10px] text-ink-mid">
|
<p className="text-[10px] text-ink-soft">
|
||||||
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +350,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
|
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
entries.length === 0 ? (
|
entries.length === 0 ? (
|
||||||
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
|
<p className="text-xs text-ink-soft text-center py-4">No memory entries</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
@ -364,11 +364,11 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
<span className="text-xs font-mono text-accent">{entry.key}</span>
|
<span className="text-xs font-mono text-accent">{entry.key}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{entry.expires_at && (
|
{entry.expires_at && (
|
||||||
<span className="text-[9px] text-ink-mid">
|
<span className="text-[9px] text-ink-soft">
|
||||||
TTL {new Date(entry.expires_at).toLocaleString()}
|
TTL {new Date(entry.expires_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-ink-mid">
|
<span className="text-[10px] text-ink-soft">
|
||||||
{expanded === entry.key ? "▼" : "▶"}
|
{expanded === entry.key ? "▼" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -420,7 +420,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[9px] text-ink-mid">
|
<span className="text-[9px] text-ink-soft">
|
||||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -452,7 +452,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
|||||||
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
|
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
|
||||||
<p className="text-[10px] text-ink-mid truncate">
|
<p className="text-[10px] text-ink-soft truncate">
|
||||||
KV entries remain available if you need the raw platform store.
|
KV entries remain available if you need the raw platform store.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -180,7 +180,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-[10px] text-ink-mid">Loading schedules...</div>;
|
return <div className="p-4 text-[10px] text-ink-soft">Loading schedules...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -207,11 +207,11 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
placeholder="Schedule name (e.g., Daily security scan)"
|
placeholder="Schedule name (e.g., Daily security scan)"
|
||||||
value={formName}
|
value={formName}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid"
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor={cronId} className="text-[10px] text-ink-mid block mb-0.5">Cron Expression</label>
|
<label htmlFor={cronId} className="text-[10px] text-ink-soft block mb-0.5">Cron Expression</label>
|
||||||
<input
|
<input
|
||||||
id={cronId}
|
id={cronId}
|
||||||
type="text"
|
type="text"
|
||||||
@ -219,12 +219,12 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
onChange={(e) => setFormCron(e.target.value)}
|
onChange={(e) => setFormCron(e.target.value)}
|
||||||
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-ink-mid mt-0.5">
|
<div className="text-[10px] text-ink-soft mt-0.5">
|
||||||
{cronToHuman(formCron)}
|
{cronToHuman(formCron)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24">
|
<div className="w-24">
|
||||||
<label htmlFor={timezoneId} className="text-[10px] text-ink-mid block mb-0.5">Timezone</label>
|
<label htmlFor={timezoneId} className="text-[10px] text-ink-soft block mb-0.5">Timezone</label>
|
||||||
<select
|
<select
|
||||||
id={timezoneId}
|
id={timezoneId}
|
||||||
value={formTimezone}
|
value={formTimezone}
|
||||||
@ -245,14 +245,14 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={promptId} className="text-[10px] text-ink-mid block mb-0.5">Prompt / Task</label>
|
<label htmlFor={promptId} className="text-[10px] text-ink-soft block mb-0.5">Prompt / Task</label>
|
||||||
<textarea
|
<textarea
|
||||||
id={promptId}
|
id={promptId}
|
||||||
value={formPrompt}
|
value={formPrompt}
|
||||||
onChange={(e) => setFormPrompt(e.target.value)}
|
onChange={(e) => setFormPrompt(e.target.value)}
|
||||||
placeholder="What should the agent do on this schedule?"
|
placeholder="What should the agent do on this schedule?"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid resize-y"
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -290,7 +290,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-ink-mid space-y-0.5">
|
<div className="text-[10px] text-ink-soft space-y-0.5">
|
||||||
<div>Common patterns:</div>
|
<div>Common patterns:</div>
|
||||||
<div className="font-mono">{"0 9 * * *"} — Daily at 9:00 AM</div>
|
<div className="font-mono">{"0 9 * * *"} — Daily at 9:00 AM</div>
|
||||||
<div className="font-mono">{"*/30 * * * *"} — Every 30 minutes</div>
|
<div className="font-mono">{"*/30 * * * *"} — Every 30 minutes</div>
|
||||||
@ -306,7 +306,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
<div className="p-6 text-center">
|
<div className="p-6 text-center">
|
||||||
<div className="text-2xl mb-2">⏲</div>
|
<div className="text-2xl mb-2">⏲</div>
|
||||||
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
|
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
|
||||||
<div className="text-[9px] text-ink-mid">
|
<div className="text-[9px] text-ink-soft">
|
||||||
Add a schedule to run tasks automatically — daily scans, periodic reports, standup reminders.
|
Add a schedule to run tasks automatically — daily scans, periodic reports, standup reminders.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -336,16 +336,16 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
{sched.name || "Unnamed schedule"}
|
{sched.name || "Unnamed schedule"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-ink-mid mt-0.5 font-mono">
|
<div className="text-[9px] text-ink-soft mt-0.5 font-mono">
|
||||||
{cronToHuman(sched.cron_expr)}
|
{cronToHuman(sched.cron_expr)}
|
||||||
{sched.timezone !== "UTC" && (
|
{sched.timezone !== "UTC" && (
|
||||||
<span className="text-ink-mid"> ({sched.timezone})</span>
|
<span className="text-ink-soft"> ({sched.timezone})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-ink-mid mt-0.5 truncate">
|
<div className="text-[9px] text-ink-soft mt-0.5 truncate">
|
||||||
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
|
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-mid">
|
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-soft">
|
||||||
<span>Last: {relativeTime(sched.last_run_at)}</span>
|
<span>Last: {relativeTime(sched.last_run_at)}</span>
|
||||||
<span>Next: {relativeTime(sched.next_run_at)}</span>
|
<span>Next: {relativeTime(sched.next_run_at)}</span>
|
||||||
<span>Runs: {sched.run_count}</span>
|
<span>Runs: {sched.run_count}</span>
|
||||||
|
|||||||
@ -320,7 +320,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
aria-label="Plugins (none installed)"
|
aria-label="Plugins (none installed)"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Plugins</span>
|
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
|
||||||
<span className="text-[11px] text-ink-mid">0 installed</span>
|
<span className="text-[11px] text-ink-mid">0 installed</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -342,7 +342,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Plugins</div>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
|
||||||
<h3 className="mt-1 text-sm font-semibold text-ink">
|
<h3 className="mt-1 text-sm font-semibold text-ink">
|
||||||
{installed.length} installed
|
{installed.length} installed
|
||||||
</h3>
|
</h3>
|
||||||
@ -379,21 +379,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] font-medium text-ink">{p.name}</span>
|
<span className="text-[11px] font-medium text-ink">{p.name}</span>
|
||||||
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
|
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
|
||||||
{inert && (
|
{inert && (
|
||||||
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm">
|
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm">
|
||||||
inert on this runtime
|
inert on this runtime
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
|
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
|
||||||
{p.skills && p.skills.length > 0 && (
|
{p.skills && p.skills.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{p.skills.slice(0, 4).map((s) => (
|
{p.skills.slice(0, 4).map((s) => (
|
||||||
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span>
|
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span>
|
||||||
))}
|
))}
|
||||||
{p.skills.length > 4 && (
|
{p.skills.length > 4 && (
|
||||||
<span className="text-[10px] text-ink-mid">+{p.skills.length - 4}</span>
|
<span className="text-[10px] text-ink-soft">+{p.skills.length - 4}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -417,7 +417,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
{/* Install from any source (github://, clawhub://, …) */}
|
{/* Install from any source (github://, clawhub://, …) */}
|
||||||
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
|
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||||
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
|
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">
|
||||||
Install from source
|
Install from source
|
||||||
</div>
|
</div>
|
||||||
{sourceSchemes.length > 0 && (
|
{sourceSchemes.length > 0 && (
|
||||||
@ -425,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
{sourceSchemes.map((s) => (
|
{sourceSchemes.map((s) => (
|
||||||
<span
|
<span
|
||||||
key={s}
|
key={s}
|
||||||
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
|
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-soft"
|
||||||
>
|
>
|
||||||
{s}://
|
{s}://
|
||||||
</span>
|
</span>
|
||||||
@ -444,7 +444,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
}}
|
}}
|
||||||
placeholder="e.g. github://owner/repo#v1.0"
|
placeholder="e.g. github://owner/repo#v1.0"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-mid focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
|
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleInstallCustom}
|
onClick={handleInstallCustom}
|
||||||
@ -454,12 +454,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
{installing === customSource.trim() ? "Installing..." : "Install"}
|
{installing === customSource.trim() ? "Installing..." : "Install"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] text-ink-mid">
|
<div className="mt-1 text-[10px] text-ink-soft">
|
||||||
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
|
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
|
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Available plugins</div>
|
||||||
{/* Retry visible whenever registry is empty — including
|
{/* Retry visible whenever registry is empty — including
|
||||||
the loading state — so a stuck fetch (Fast Refresh
|
the loading state — so a stuck fetch (Fast Refresh
|
||||||
stranded promise, slow server, browser quirk) has a
|
stranded promise, slow server, browser quirk) has a
|
||||||
@ -486,21 +486,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{registryLoading && registry.length === 0 ? (
|
{registryLoading && registry.length === 0 ? (
|
||||||
<div className="text-[10px] text-ink-mid">Loading registry…</div>
|
<div className="text-[10px] text-ink-soft">Loading registry…</div>
|
||||||
) : registryError ? (
|
) : registryError ? (
|
||||||
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
|
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
|
||||||
<div className="text-[10px] text-bad font-semibold mb-0.5">
|
<div className="text-[10px] text-bad font-semibold mb-0.5">
|
||||||
Couldn't load the plugin registry
|
Couldn't load the plugin registry
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-bad/80">{registryError}</div>
|
<div className="text-[10px] text-bad/80">{registryError}</div>
|
||||||
<div className="mt-1 text-[10px] text-ink-mid">
|
<div className="mt-1 text-[10px] text-ink-soft">
|
||||||
Check the platform server is reachable at /plugins. The Retry button is in the header above.
|
Check the platform server is reachable at /plugins. The Retry button is in the header above.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : registry.length === 0 ? (
|
) : registry.length === 0 ? (
|
||||||
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
|
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
|
||||||
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
|
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
|
||||||
<div className="text-[10px] text-ink-mid">
|
<div className="text-[10px] text-ink-soft">
|
||||||
This usually means the platform's plugins/ directory is empty.
|
This usually means the platform's plugins/ directory is empty.
|
||||||
Run scripts/clone-manifest.sh to populate it from the standalone repos.
|
Run scripts/clone-manifest.sh to populate it from the standalone repos.
|
||||||
</div>
|
</div>
|
||||||
@ -514,13 +514,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] text-ink-mid">{p.name}</span>
|
<span className="text-[11px] text-ink-mid">{p.name}</span>
|
||||||
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
|
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
|
||||||
</div>
|
</div>
|
||||||
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
|
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
|
||||||
{p.tags && p.tags.length > 0 && (
|
{p.tags && p.tags.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{p.tags.map((t) => (
|
{p.tags.map((t) => (
|
||||||
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-mid">{t}</span>
|
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-soft">{t}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -556,7 +556,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Workspace skills</div>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Workspace skills</div>
|
||||||
<h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3>
|
<h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -564,7 +564,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] leading-5 text-ink-mid">
|
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
|
||||||
Live skill directory from the Agent Card — updates when the workspace hot-reloads skills.
|
Live skill directory from the Agent Card — updates when the workspace hot-reloads skills.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
@ -593,7 +593,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
{skills.length === 0 ? (
|
{skills.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center">
|
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center">
|
||||||
<div className="text-sm text-ink">No skills loaded</div>
|
<div className="text-sm text-ink">No skills loaded</div>
|
||||||
<p className="mt-2 text-[11px] leading-5 text-ink-mid">
|
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
|
||||||
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
|
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -604,7 +604,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-ink">{skill.name}</div>
|
<div className="text-xs font-semibold text-ink">{skill.name}</div>
|
||||||
<div className="mt-0.5 text-[10px] font-mono text-ink-mid">{skill.id}</div>
|
<div className="mt-0.5 text-[10px] font-mono text-ink-soft">{skill.id}</div>
|
||||||
</div>
|
</div>
|
||||||
{skill.tags.length > 0 && (
|
{skill.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
<div className="flex flex-wrap justify-end gap-1.5">
|
||||||
@ -626,7 +626,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
|
|
||||||
{skill.examples.length > 0 && (
|
{skill.examples.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-mid">Examples</div>
|
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-soft">Examples</div>
|
||||||
<div className="mt-1 space-y-1">
|
<div className="mt-1 space-y-1">
|
||||||
{skill.examples.slice(0, 2).map((example, index) => (
|
{skill.examples.slice(0, 2).map((example, index) => (
|
||||||
<div
|
<div
|
||||||
@ -666,7 +666,7 @@ function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[]
|
|||||||
function MetaPill({ label, value }: { label: string; value: string }) {
|
function MetaPill({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
|
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
|
||||||
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-mid">{label}</span>
|
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-soft">{label}</span>
|
||||||
<span className="font-medium">{value}</span>
|
<span className="font-medium">{value}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,7 +37,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
|
|||||||
viewBox="0 0 72 72"
|
viewBox="0 0 72 72"
|
||||||
fill="none"
|
fill="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="text-ink-mid mb-4"
|
className="text-ink-soft mb-4"
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
x="10"
|
x="10"
|
||||||
@ -74,7 +74,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3>
|
<h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3>
|
||||||
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed">
|
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
|
||||||
This workspace runs the{" "}
|
This workspace runs the{" "}
|
||||||
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
||||||
which doesn't expose a shell. Use the Chat tab to interact with the
|
which doesn't expose a shell. Use the Chat tab to interact with the
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
}, [loadTraces]);
|
}, [loadTraces]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-xs text-ink-mid">Loading traces...</div>;
|
return <div className="p-4 text-xs text-ink-soft">Loading traces...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
onClick={loadTraces}
|
onClick={loadTraces}
|
||||||
// Added focus-visible ring; previous version was hover-only,
|
// Added focus-visible ring; previous version was hover-only,
|
||||||
// invisible to keyboard users.
|
// invisible to keyboard users.
|
||||||
className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
className="text-[10px] text-ink-soft hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@ -75,9 +75,9 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
{traces.length === 0 && !error ? (
|
{traces.length === 0 && !error ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
|
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
|
||||||
<p className="text-xs text-ink-mid">No traces yet</p>
|
<p className="text-xs text-ink-soft">No traces yet</p>
|
||||||
<details className="mt-2 text-[10px] text-ink-mid">
|
<details className="mt-2 text-[10px] text-ink-soft">
|
||||||
<summary className="cursor-pointer text-ink-mid hover:text-ink-mid">How to enable tracing</summary>
|
<summary className="cursor-pointer text-ink-soft hover:text-ink-mid">How to enable tracing</summary>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
Set <code className="font-mono text-ink-mid">LANGFUSE_HOST</code>, <code className="font-mono text-ink-mid">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-ink-mid">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing.
|
Set <code className="font-mono text-ink-mid">LANGFUSE_HOST</code>, <code className="font-mono text-ink-mid">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-ink-mid">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing.
|
||||||
</p>
|
</p>
|
||||||
@ -108,20 +108,20 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
|
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
|
||||||
<div className="text-[9px] text-ink-mid">{formatTime(trace.timestamp)}</div>
|
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{trace.latency != null && (
|
{trace.latency != null && (
|
||||||
<span className="text-[9px] text-ink-mid tabular-nums">
|
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||||
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
|
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{trace.usage?.total != null && (
|
{trace.usage?.total != null && (
|
||||||
<span className="text-[9px] text-ink-mid tabular-nums">
|
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||||
{trace.usage.total} tok
|
{trace.usage.total} tok
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span aria-hidden="true" className="text-[9px] text-ink-mid">
|
<span aria-hidden="true" className="text-[9px] text-ink-soft">
|
||||||
{isOpen ? "▼" : "▶"}
|
{isOpen ? "▼" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -131,7 +131,7 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
|
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
|
||||||
{trace.input && (
|
{trace.input && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] text-ink-mid uppercase tracking-wider mt-2 mb-1">Input</div>
|
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
|
||||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||||
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
|
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
|
||||||
</pre>
|
</pre>
|
||||||
@ -139,18 +139,18 @@ export function TracesTab({ workspaceId }: Props) {
|
|||||||
)}
|
)}
|
||||||
{trace.output && (
|
{trace.output && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[9px] text-ink-mid uppercase tracking-wider mb-1">Output</div>
|
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
|
||||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||||
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
|
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{trace.totalCost != null && (
|
{trace.totalCost != null && (
|
||||||
<div className="text-[9px] text-ink-mid">
|
<div className="text-[9px] text-ink-soft">
|
||||||
Cost: ${trace.totalCost.toFixed(6)}
|
Cost: ${trace.totalCost.toFixed(6)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[8px] text-ink-mid font-mono select-all">
|
<div className="text-[8px] text-ink-soft font-mono select-all">
|
||||||
{trace.id}
|
{trace.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -389,7 +389,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-xs text-ink-mid text-center py-8">Loading agent communications...</div>;
|
return <div className="text-xs text-ink-soft text-center py-8">Loading agent communications...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadError !== null && messages.length === 0) {
|
if (loadError !== null && messages.length === 0) {
|
||||||
@ -415,10 +415,10 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
|||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">
|
<div className="text-xs text-ink-soft text-center py-8">
|
||||||
No agent-to-agent communications yet.
|
No agent-to-agent communications yet.
|
||||||
<br />
|
<br />
|
||||||
<span className="text-ink-mid">Delegations and peer messages will appear here.</span>
|
<span className="text-ink-soft">Delegations and peer messages will appear here.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -513,20 +513,7 @@ function GroupedCommsView({
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
{visible.map((msg) =>
|
{visible.map((msg) =>
|
||||||
// Only render the error UI when there is NO usable response
|
msg.status === "error" ? (
|
||||||
// content. A "error" status from the platform means the HTTP
|
|
||||||
// transport layer had a problem — but the agent response text
|
|
||||||
// may have arrived and been stored in response_body.text.
|
|
||||||
// Delegation results set responseText via extractResponseText
|
|
||||||
// once that function learned to parse body.text, so checking
|
|
||||||
// !msg.responseText here correctly identifies "no actual reply
|
|
||||||
// was received" vs. "reply arrived but status=error".
|
|
||||||
//
|
|
||||||
// Without this guard, successful delegation results were
|
|
||||||
// rendered as error banners, PMs saw "restart" prompts and
|
|
||||||
// restarted working agents, and retry storms formed as the
|
|
||||||
// platform re-delivered the same completed work (issue #159).
|
|
||||||
msg.status === "error" && !msg.responseText ? (
|
|
||||||
<ErrorMessage key={msg.id} msg={msg} />
|
<ErrorMessage key={msg.id} msg={msg} />
|
||||||
) : (
|
) : (
|
||||||
<NormalMessage key={msg.id} msg={msg} />
|
<NormalMessage key={msg.id} msg={msg} />
|
||||||
@ -613,10 +600,10 @@ function PeerTabButton({
|
|||||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
|
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
|
||||||
active
|
active
|
||||||
? "border-b-2 border-cyan-500 text-cyan-200"
|
? "border-b-2 border-cyan-500 text-cyan-200"
|
||||||
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
|
: "border-b-2 border-transparent text-ink-soft hover:text-ink-mid"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label} <span className="text-[9px] text-ink-mid">({count})</span>
|
{label} <span className="text-[9px] text-ink-soft">({count})</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -669,7 +656,7 @@ function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
|
|||||||
role="status"
|
role="status"
|
||||||
aria-label={`Waiting for reply from ${m.peerName}`}
|
aria-label={`Waiting for reply from ${m.peerName}`}
|
||||||
>
|
>
|
||||||
<div className="text-[9px] text-ink-mid mb-1">→ To {m.peerName}</div>
|
<div className="text-[9px] text-ink-soft mb-1">→ To {m.peerName}</div>
|
||||||
<span className="flex items-center gap-2 text-ink-mid">
|
<span className="flex items-center gap-2 text-ink-mid">
|
||||||
<span className="flex gap-0.5" aria-hidden="true">
|
<span className="flex gap-0.5" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
@ -708,7 +695,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
|
|||||||
: "bg-surface-card/80 text-ink border border-line/30"
|
: "bg-surface-card/80 text-ink border border-line/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-[9px] text-ink-mid mb-1">
|
<div className="text-[9px] text-ink-soft mb-1">
|
||||||
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||||
</div>
|
</div>
|
||||||
{msg.text ? (
|
{msg.text ? (
|
||||||
@ -731,7 +718,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
|
|||||||
{msg.responseText}
|
{msg.responseText}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
)}
|
)}
|
||||||
<div className="text-[9px] text-ink-mid mt-1">
|
<div className="text-[9px] text-ink-soft mt-1">
|
||||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -804,7 +791,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg.text && (
|
{msg.text && (
|
||||||
<div className="text-[10px] text-ink-mid mb-1.5">
|
<div className="text-[10px] text-ink-soft mb-1.5">
|
||||||
<span className="uppercase tracking-wide">Task</span>
|
<span className="uppercase tracking-wide">Task</span>
|
||||||
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
|
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
|
||||||
</div>
|
</div>
|
||||||
@ -841,7 +828,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-[9px] text-ink-mid mt-1.5">
|
<div className="text-[9px] text-ink-soft mt-1.5">
|
||||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
// AttachmentLightbox).
|
// AttachmentLightbox).
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { platformAuthHeaders } from "@/lib/api";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@ -44,8 +43,13 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
@ -112,5 +116,9 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local getTenantSlug() removed — auth-header construction now goes
|
function getTenantSlug(): string | null {
|
||||||
// through platformAuthHeaders() from @/lib/api (#178).
|
if (typeof window === "undefined") return null;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -35,7 +35,6 @@
|
|||||||
// downscale via canvas, but defer that to v2.
|
// downscale via canvas, but defer that to v2.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { platformAuthHeaders } from "@/lib/api";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||||
@ -76,14 +75,22 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Platform-auth path: identical to downloadChatFile but we keep
|
// Platform-auth path: identical to downloadChatFile but we keep
|
||||||
// the blob (don't trigger a Save-As). Auth headers come from the
|
// the blob (don't trigger a Save-As). Use the same headers it does
|
||||||
// shared `platformAuthHeaders()` helper — one source of truth for
|
// by going through it indirectly — no, downloadChatFile triggers a
|
||||||
// every authenticated raw fetch in the canvas (#178).
|
// Save-As. Need a separate fetch.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
// Read the same env var downloadChatFile reads — single source
|
||||||
|
// of truth would be cleaner; refactor opportunity for PR-2 if
|
||||||
|
// we add the same path to AttachmentVideo.
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
@ -177,7 +184,15 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local getTenantSlug() removed — auth-header construction now goes
|
// Internal helper — duplicated from uploads.ts (it's not exported
|
||||||
// through platformAuthHeaders() from @/lib/api which uses the canonical
|
// there). Kept local so this component doesn't reach into private
|
||||||
// getTenantSlug() from @/lib/tenant. This eliminates the duplicate
|
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
|
||||||
// hostname-regex + the duplicate bearer-token-attach pattern (#178).
|
// it, lift to an exported helper at that point (the third-caller
|
||||||
|
// rule).
|
||||||
|
function getTenantSlug(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
// Tenant subdomain shape: <slug>.moleculesai.app
|
||||||
|
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -33,7 +33,6 @@
|
|||||||
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { platformAuthHeaders } from "@/lib/api";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||||
@ -70,8 +69,13 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
@ -185,5 +189,9 @@ function PdfGlyph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local getTenantSlug() removed — auth-header construction now goes
|
function getTenantSlug(): string | null {
|
||||||
// through platformAuthHeaders() from @/lib/api (#178).
|
if (typeof window === "undefined") return null;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@
|
|||||||
// to download the full file.
|
// to download the full file.
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { platformAuthHeaders } from "@/lib/api";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@ -58,13 +57,13 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
// Only attach platform auth headers for in-platform URIs —
|
const headers: Record<string, string> = {};
|
||||||
// off-platform URLs (HTTP/HTTPS attachments) MUST NOT receive
|
if (isPlatformAttachment(attachment.uri)) {
|
||||||
// our bearer token (it would leak the admin token to a third
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
// party). The branch is preserved with the new shared helper.
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
const headers: Record<string, string> = isPlatformAttachment(attachment.uri)
|
const slug = getTenantSlug();
|
||||||
? platformAuthHeaders()
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
: {};
|
}
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@ -148,7 +147,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDownload(attachment)}
|
onClick={() => onDownload(attachment)}
|
||||||
className="text-ink-mid hover:text-ink"
|
className="text-ink-soft hover:text-ink"
|
||||||
title={`Download ${attachment.name}`}
|
title={`Download ${attachment.name}`}
|
||||||
aria-label={`Download ${attachment.name}`}
|
aria-label={`Download ${attachment.name}`}
|
||||||
>
|
>
|
||||||
@ -183,5 +182,9 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local getTenantSlug() removed — auth-header construction now goes
|
function getTenantSlug(): string | null {
|
||||||
// through platformAuthHeaders() from @/lib/api (#178).
|
if (typeof window === "undefined") return null;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -25,7 +25,6 @@
|
|||||||
// fetch via service worker. v2 if measured-needed.
|
// fetch via service worker. v2 if measured-needed.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { platformAuthHeaders } from "@/lib/api";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@ -62,8 +61,13 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
// Videos are larger than images on average; give the request
|
// Videos are larger than images on average; give the request
|
||||||
// more headroom. The server's per-request body cap (50MB) is
|
// more headroom. The server's per-request body cap (50MB) is
|
||||||
@ -143,5 +147,11 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local getTenantSlug() removed — auth-header construction now goes
|
// Internal helper — same shape as AttachmentImage's. Lifted to a
|
||||||
// through platformAuthHeaders() from @/lib/api (#178).
|
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
|
||||||
|
function getTenantSlug(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -29,11 +29,11 @@ export function PendingAttachmentPill({
|
|||||||
<div className="flex items-center gap-1.5 rounded-md border border-line/60 bg-surface-card/80 px-2 py-1 text-[10px] text-ink-mid max-w-[200px]">
|
<div className="flex items-center gap-1.5 rounded-md border border-line/60 bg-surface-card/80 px-2 py-1 text-[10px] text-ink-mid max-w-[200px]">
|
||||||
<FileGlyph className="text-ink-mid shrink-0" />
|
<FileGlyph className="text-ink-mid shrink-0" />
|
||||||
<span className="truncate" title={file.name}>{file.name}</span>
|
<span className="truncate" title={file.name}>{file.name}</span>
|
||||||
<span className="text-ink-mid shrink-0 tabular-nums">{formatSize(file.size)}</span>
|
<span className="text-ink-soft shrink-0 tabular-nums">{formatSize(file.size)}</span>
|
||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label={`Remove ${file.name}`}
|
aria-label={`Remove ${file.name}`}
|
||||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0"
|
className="ml-0.5 text-ink-soft hover:text-ink transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
|||||||
@ -4,11 +4,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|||||||
|
|
||||||
// API mock — tests can override per case via apiGetMock.mockImplementationOnce.
|
// API mock — tests can override per case via apiGetMock.mockImplementationOnce.
|
||||||
const apiGetMock = vi.fn<(url: string) => Promise<unknown>>();
|
const apiGetMock = vi.fn<(url: string) => Promise<unknown>>();
|
||||||
const apiPostMock = vi.fn<(url: string, body?: unknown) => Promise<unknown>>();
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
get: (url: string) => apiGetMock(url),
|
get: (url: string) => apiGetMock(url),
|
||||||
post: (url: string, body?: unknown) => apiPostMock(url, body),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -18,23 +16,17 @@ vi.mock("@/hooks/useSocketEvent", () => ({
|
|||||||
useSocketEvent: () => {},
|
useSocketEvent: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Canvas store — peer name resolution + ErrorMessage requires selectNode
|
// Canvas store — peer name resolution.
|
||||||
// (Zustand hook usage). The mock must support BOTH:
|
vi.mock("@/store/canvas", () => ({
|
||||||
// useCanvasStore.getState().nodes (plain object with getState)
|
useCanvasStore: {
|
||||||
// useCanvasStore((s) => s.selectNode) (Zustand hook with selector)
|
getState: () => ({
|
||||||
vi.mock("@/store/canvas", () => {
|
|
||||||
const state = {
|
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: "ws-self", data: { name: "Self" } },
|
{ id: "ws-self", data: { name: "Self" } },
|
||||||
{ id: "ws-peer", data: { name: "Peer Agent" } },
|
{ id: "ws-peer", data: { name: "Peer Agent" } },
|
||||||
],
|
],
|
||||||
selectNode: vi.fn(),
|
}),
|
||||||
};
|
},
|
||||||
const hook = (selector?: (s: typeof state) => unknown) =>
|
}));
|
||||||
selector ? selector(state) : state;
|
|
||||||
hook.getState = () => state;
|
|
||||||
return { useCanvasStore: hook };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toaster shim — AgentCommsPanel imports showToast.
|
// Toaster shim — AgentCommsPanel imports showToast.
|
||||||
vi.mock("../../Toaster", () => ({
|
vi.mock("../../Toaster", () => ({
|
||||||
@ -49,8 +41,6 @@ import { AgentCommsPanel } from "../AgentCommsPanel";
|
|||||||
const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>();
|
const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>();
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGetMock.mockReset();
|
apiGetMock.mockReset();
|
||||||
apiPostMock.mockReset();
|
|
||||||
apiPostMock.mockResolvedValue({});
|
|
||||||
scrollSpy.mockReset();
|
scrollSpy.mockReset();
|
||||||
Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"];
|
Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"];
|
||||||
});
|
});
|
||||||
@ -59,81 +49,6 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression test: when a delegation succeeds but the platform persisted
|
|
||||||
// status="error" (transport-layer HTTP failure, not agent failure), the
|
|
||||||
// canvas had the response text in msg.text but rendered ErrorMessage
|
|
||||||
// anyway, burying the real content in an "Underlying error" banner and
|
|
||||||
// prompting PMs to restart working agents (issue #159).
|
|
||||||
describe("AgentCommsPanel — error rendering guard (issue #159)", () => {
|
|
||||||
it("renders NormalMessage when status=error but msg.text is present (successful delegation)", async () => {
|
|
||||||
// Simulate a delegation result where status="error" (HTTP transport
|
|
||||||
// failed) but response_body.text carries the actual agent response.
|
|
||||||
// The correct behaviour: show the content as a normal inbound bubble,
|
|
||||||
// NOT an error banner.
|
|
||||||
apiGetMock.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: "act-1",
|
|
||||||
activity_type: "delegation",
|
|
||||||
method: "delegate_result",
|
|
||||||
source_id: "ws-self",
|
|
||||||
target_id: "ws-peer",
|
|
||||||
summary: "Delegation completed",
|
|
||||||
request_body: null,
|
|
||||||
// delegation.go stores response_body as {text: "...", delegation_id: "..."}
|
|
||||||
response_body: {
|
|
||||||
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
|
|
||||||
delegation_id: "delg_01jx8q4n3k",
|
|
||||||
},
|
|
||||||
status: "error", // transport-layer error, not agent failure
|
|
||||||
created_at: "2026-04-25T18:00:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
render(<AgentCommsPanel workspaceId="ws-self" />);
|
|
||||||
|
|
||||||
// The response text should appear in a normal inbound bubble, NOT in
|
|
||||||
// an error banner. Specifically: no "Failed to deliver" or "returned
|
|
||||||
// an error" text should appear.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(/failed to deliver/i)).toBeNull();
|
|
||||||
expect(screen.queryByText(/returned an error/i)).toBeNull();
|
|
||||||
});
|
|
||||||
// The actual content must be visible.
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByText(/tier-check fails NO REVIEWS/i),
|
|
||||||
).toBeDefined(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders ErrorMessage when status=error and msg.text is absent (true failure)", async () => {
|
|
||||||
// True delivery failure: no response body, no text. The error banner
|
|
||||||
// IS appropriate here.
|
|
||||||
apiGetMock.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: "act-1",
|
|
||||||
activity_type: "a2a_send",
|
|
||||||
source_id: "ws-self",
|
|
||||||
target_id: "ws-peer",
|
|
||||||
method: "message/send",
|
|
||||||
summary: "A2A send failed",
|
|
||||||
request_body: null,
|
|
||||||
response_body: null,
|
|
||||||
status: "error",
|
|
||||||
created_at: "2026-04-25T18:00:00Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
render(<AgentCommsPanel workspaceId="ws-self" />);
|
|
||||||
|
|
||||||
// Error banner IS shown for true failures (no content).
|
|
||||||
// jsdom doesn't reliably match role="alert" in getByRole, so use
|
|
||||||
// getByText instead.
|
|
||||||
const errorBanner = await waitFor(() =>
|
|
||||||
screen.getByText(/failed to deliver/i),
|
|
||||||
);
|
|
||||||
expect(errorBanner).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => {
|
describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => {
|
||||||
it("shows loading text while history fetch is in flight", () => {
|
it("shows loading text while history fetch is in flight", () => {
|
||||||
apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));
|
apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));
|
||||||
|
|||||||
@ -64,54 +64,6 @@ describe("extractRequestText", () => {
|
|||||||
};
|
};
|
||||||
expect(extractRequestText(body)).toBe("");
|
expect(extractRequestText(body)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression: delegation.go stores request_body as {"task": "...", "delegation_id": "..."}.
|
|
||||||
// extractRequestText was checking only the A2A params.message.parts path, so
|
|
||||||
// outbound delegation messages were rendered as blank bubbles.
|
|
||||||
// Fix: check body.task first (delegation format), then fall back to A2A.
|
|
||||||
it("extracts text from body.task (delegation format)", () => {
|
|
||||||
const body = {
|
|
||||||
task: "Deploy the staging environment for this sprint's release",
|
|
||||||
delegation_id: "delg_01jx8q4n3k",
|
|
||||||
};
|
|
||||||
expect(extractRequestText(body)).toBe(
|
|
||||||
"Deploy the staging environment for this sprint's release"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers body.task over A2A params when both present", () => {
|
|
||||||
const body = {
|
|
||||||
task: "Delegation text wins",
|
|
||||||
params: {
|
|
||||||
message: {
|
|
||||||
parts: [{ kind: "text", text: "A2A text" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// body.task is checked first; delegation wins for delegation activities.
|
|
||||||
expect(extractRequestText(body)).toBe("Delegation text wins");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to A2A format when body.task is absent", () => {
|
|
||||||
const body = {
|
|
||||||
params: {
|
|
||||||
message: {
|
|
||||||
parts: [{ kind: "text", text: "A2A fallback" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(extractRequestText(body)).toBe("A2A fallback");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string when body.task is empty string", () => {
|
|
||||||
const body = { task: "" };
|
|
||||||
expect(extractRequestText(body)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string when body.task is not a string", () => {
|
|
||||||
const body = { task: 42 };
|
|
||||||
expect(extractRequestText(body)).toBe("");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractResponseText", () => {
|
describe("extractResponseText", () => {
|
||||||
@ -209,43 +161,6 @@ describe("extractResponseText", () => {
|
|||||||
};
|
};
|
||||||
expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two");
|
expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression: delegation.go stores response_body as
|
|
||||||
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
|
|
||||||
// Without body.text handling, extractResponseText returns "" for
|
|
||||||
// delegate_result rows, causing the error UI to fire even when the
|
|
||||||
// delegation succeeded (issue #159).
|
|
||||||
it("extracts from body.text (delegation response_body shape)", () => {
|
|
||||||
const body = {
|
|
||||||
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
|
|
||||||
delegation_id: "delg_01jx8q4n3k",
|
|
||||||
};
|
|
||||||
expect(extractResponseText(body)).toBe(
|
|
||||||
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers body.result over body.text when both present", () => {
|
|
||||||
const body = {
|
|
||||||
result: { parts: [{ kind: "text", text: "A2A result wins" }] },
|
|
||||||
text: "Delegation text",
|
|
||||||
};
|
|
||||||
// result path is checked first; A2A wins when both present.
|
|
||||||
expect(extractResponseText(body)).toBe("A2A result wins");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string when body.text is empty string", () => {
|
|
||||||
expect(extractResponseText({ text: "" })).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts from body.response_preview (DELEGATION_COMPLETE WS event shape)", () => {
|
|
||||||
const body = {
|
|
||||||
response_preview: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
|
|
||||||
};
|
|
||||||
expect(extractResponseText(body)).toBe(
|
|
||||||
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractTextsFromParts", () => {
|
describe("extractTextsFromParts", () => {
|
||||||
|
|||||||
@ -114,15 +114,9 @@ function basename(uri: string): string {
|
|||||||
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file";
|
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract user message text from an activity log request_body.
|
/** Extract user message text from an activity log request_body */
|
||||||
*
|
|
||||||
* Delegation activities from delegation.go store the task text directly
|
|
||||||
* at `body.task` as a plain string: {"task": "...", "delegation_id": "..."}.
|
|
||||||
* Check this first before falling back to the A2A JSON-RPC format
|
|
||||||
* (`body.params.message.parts[].text`). */
|
|
||||||
export function extractRequestText(body: Record<string, unknown> | null): string {
|
export function extractRequestText(body: Record<string, unknown> | null): string {
|
||||||
if (!body) return "";
|
if (!body) return "";
|
||||||
if (typeof body.task === "string" && body.task) return body.task;
|
|
||||||
const params = body.params as Record<string, unknown> | undefined;
|
const params = body.params as Record<string, unknown> | undefined;
|
||||||
const msg = params?.message as Record<string, unknown> | undefined;
|
const msg = params?.message as Record<string, unknown> | undefined;
|
||||||
const parts = msg?.parts as Array<Record<string, unknown>> | undefined;
|
const parts = msg?.parts as Array<Record<string, unknown>> | undefined;
|
||||||
@ -168,10 +162,10 @@ export function extractResponseText(body: Record<string, unknown>): string {
|
|||||||
if (rootTexts.length > 0) collected.push(rootTexts.join("\n"));
|
if (rootTexts.length > 0) collected.push(rootTexts.join("\n"));
|
||||||
|
|
||||||
// Task shape: {result: {artifacts: [{parts: [...]}]}}
|
// Task shape: {result: {artifacts: [{parts: [...]}]}}
|
||||||
const artifacts = result.artifacts as Array<Record<string, unknown> | undefined>;
|
const artifacts = result.artifacts as Array<Record<string, unknown>> | undefined;
|
||||||
if (artifacts) {
|
if (artifacts) {
|
||||||
for (const a of artifacts) {
|
for (const a of artifacts) {
|
||||||
const t = extractTextsFromParts(a?.parts);
|
const t = extractTextsFromParts(a.parts);
|
||||||
if (t) collected.push(t);
|
if (t) collected.push(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,20 +173,6 @@ export function extractResponseText(body: Record<string, unknown>): string {
|
|||||||
if (collected.length > 0) return collected.join("\n");
|
if (collected.length > 0) return collected.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegation results from delegation.go store response_body as
|
|
||||||
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
|
|
||||||
// Check this after the body.result path so A2A responses take
|
|
||||||
// precedence when both shapes are somehow present.
|
|
||||||
// Without this, responseText is always "" for delegate_result rows,
|
|
||||||
// causing the error UI to fire even when the delegation succeeded
|
|
||||||
// (issue #159).
|
|
||||||
if (typeof body.text === "string" && body.text) return body.text;
|
|
||||||
// DELEGATION_COMPLETE event (via canvas-events WS handler) stores
|
|
||||||
// response_body as {response_preview: "..."}. Handle this too.
|
|
||||||
if (typeof body.response_preview === "string" && body.response_preview) {
|
|
||||||
return body.response_preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
// {task: "text"} — request body format, shouldn't be in response but handle it
|
// {task: "text"} — request body format, shouldn't be in response but handle it
|
||||||
if (typeof body.task === "string") return body.task;
|
if (typeof body.task === "string") return body.task;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api";
|
import { PLATFORM_URL } from "@/lib/api";
|
||||||
|
import { getTenantSlug } from "@/lib/tenant";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
|
|
||||||
/** Chat attachments are intentionally uploaded via a direct fetch()
|
/** Chat attachments are intentionally uploaded via a direct fetch()
|
||||||
* instead of the `api.post` helper — `api.post` JSON-stringifies the
|
* instead of the `api.post` helper — `api.post` JSON-stringifies the
|
||||||
* body, which would 500 on a Blob. Auth headers (tenant slug, admin
|
* body, which would 500 on a Blob. Mirrors the header plumbing
|
||||||
* token, credentials) come from `platformAuthHeaders()` — the same
|
* (tenant slug, admin token, credentials) so SaaS + self-hosted
|
||||||
* helper `request()` uses, so a missing bearer surfaces as a single
|
* callers work the same way. */
|
||||||
* fix site instead of N copies. We deliberately do NOT set
|
|
||||||
* Content-Type so the browser writes the multipart boundary into the
|
|
||||||
* header; setting it manually would yield a multipart body the server
|
|
||||||
* can't parse. See lib/api.ts platformAuthHeaders() for the full
|
|
||||||
* rationale on why this pair must stay matched. */
|
|
||||||
export async function uploadChatFiles(
|
export async function uploadChatFiles(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
@ -20,12 +16,18 @@ export async function uploadChatFiles(
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const f of files) form.append("files", f, f.name);
|
for (const f of files) form.append("files", f, f.name);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
|
||||||
// Uploads legitimately take a while on cold cache (tar write +
|
// Uploads legitimately take a while on cold cache (tar write +
|
||||||
// docker cp into the container). 60s is comfortable for the 25MB/
|
// docker cp into the container). 60s is comfortable for the 25MB/
|
||||||
// 50MB caps the server enforces.
|
// 50MB caps the server enforces.
|
||||||
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
|
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
body: form,
|
body: form,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
@ -141,8 +143,14 @@ export async function downloadChatFile(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers: platformAuthHeaders(),
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export function TextInput({ label, value, onChange, placeholder, mono }: { label
|
|||||||
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
const id = `textinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label>
|
<label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="text"
|
type="text"
|
||||||
@ -68,7 +68,7 @@ export function NumberInput({ label, value, onChange, min, max }: { label: strin
|
|||||||
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
const id = `numberinput-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label>
|
<label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
@ -97,12 +97,12 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={id} className="text-[10px] text-ink-mid block mb-1">{label}</label>
|
<label htmlFor={id} className="text-[10px] text-ink-soft block mb-1">{label}</label>
|
||||||
<div className="flex flex-wrap gap-1 mb-1">
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
{values.map((v, i) => (
|
{values.map((v, i) => (
|
||||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-card border border-line rounded text-[10px] text-ink-mid font-mono">
|
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-card border border-line rounded text-[10px] text-ink-mid font-mono">
|
||||||
{v}
|
{v}
|
||||||
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-mid hover:text-bad">×</button>
|
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-soft hover:text-bad">×</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -101,9 +101,9 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] text-ink-mid">{label}</div>
|
<div className="text-[10px] text-ink-mid">{label}</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-[9px] font-mono text-ink-mid">{secretKey}</span>
|
<span className="text-[9px] font-mono text-ink-soft">{secretKey}</span>
|
||||||
{isSet && (
|
{isSet && (
|
||||||
<span className="text-[9px] font-mono text-ink-mid tracking-widest" title="Value is set (encrypted)">
|
<span className="text-[9px] font-mono text-ink-soft tracking-widest" title="Value is set (encrypted)">
|
||||||
•••••
|
•••••
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -159,7 +159,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
|
|||||||
<span className={`text-[10px] font-mono ${globalMode ? "text-warm" : scope === "global" ? "text-ink-mid" : "text-accent"}`}>
|
<span className={`text-[10px] font-mono ${globalMode ? "text-warm" : scope === "global" ? "text-ink-mid" : "text-accent"}`}>
|
||||||
{secretKey}
|
{secretKey}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] font-mono text-ink-mid tracking-widest ml-2">•••••</span>
|
<span className="text-[9px] font-mono text-ink-soft tracking-widest ml-2">•••••</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-[10px] text-good">Set</span>
|
<span className="text-[10px] text-good">Set</span>
|
||||||
@ -288,7 +288,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
|||||||
return (
|
return (
|
||||||
<Section title="Secrets & API Keys" defaultOpen={false}>
|
<Section title="Secrets & API Keys" defaultOpen={false}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-[10px] text-ink-mid">Loading secrets...</div>
|
<div className="text-[10px] text-ink-soft">Loading secrets...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||||
@ -369,7 +369,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-[9px] text-ink-mid pt-1">
|
<div className="text-[9px] text-ink-soft pt-1">
|
||||||
Values are encrypted and never exposed to the browser.
|
Values are encrypted and never exposed to the browser.
|
||||||
{globalMode
|
{globalMode
|
||||||
? " Global keys are shared across all workspaces. Restart workspaces to apply changes."
|
? " Global keys are shared across all workspaces. Restart workspaces to apply changes."
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
// @vitest-environment node
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
|
|
||||||
// Tests for the boot-time matched-pair guard added to next.config.ts.
|
|
||||||
//
|
|
||||||
// Why this lives in src/lib/__tests__ even though the function is in
|
|
||||||
// canvas/next.config.ts:
|
|
||||||
// - next.config.ts runs as ESM-but-also-CJS depending on which
|
|
||||||
// consumer loads it (Next.js dev server vs Next.js build); we
|
|
||||||
// want the test to be a plain ESM module Vitest already handles.
|
|
||||||
// - Importing from "../../../next.config" pulls in the rest of the
|
|
||||||
// file (loadMonorepoEnv, the default export, etc.) which has
|
|
||||||
// side effects on module load (it runs loadMonorepoEnv()
|
|
||||||
// immediately). To keep the test hermetic we don't import — we
|
|
||||||
// duplicate the function under test.
|
|
||||||
//
|
|
||||||
// Sourcing the function from a shared module would be cleaner, but
|
|
||||||
// next.config.ts is required to be a single self-contained file by
|
|
||||||
// Next.js's loader on some host configurations. Pin invariant: the
|
|
||||||
// duplicated function below MUST stay byte-identical to the one in
|
|
||||||
// next.config.ts. If you change one, change the other and bump this
|
|
||||||
// comment.
|
|
||||||
|
|
||||||
function checkAdminTokenPair(): void {
|
|
||||||
const serverSet = !!process.env.ADMIN_TOKEN;
|
|
||||||
const clientSet = !!process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
if (serverSet === clientSet) return;
|
|
||||||
if (serverSet && !clientSet) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
|
||||||
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
|
|
||||||
"canvas will 401 against workspace-server because the bearer header " +
|
|
||||||
"is never attached. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(
|
|
||||||
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
|
|
||||||
"workspace-server will reject the bearer because no AdminAuth gate " +
|
|
||||||
"is configured. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("checkAdminTokenPair", () => {
|
|
||||||
// Snapshot env so individual tests can stomp on it without leaking.
|
|
||||||
// Rebuild from snapshot in afterEach so the next test sees a known
|
|
||||||
// baseline regardless of mutation pattern.
|
|
||||||
let originalEnv: Record<string, string | undefined>;
|
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalEnv = {
|
|
||||||
ADMIN_TOKEN: process.env.ADMIN_TOKEN,
|
|
||||||
NEXT_PUBLIC_ADMIN_TOKEN: process.env.NEXT_PUBLIC_ADMIN_TOKEN,
|
|
||||||
};
|
|
||||||
delete process.env.ADMIN_TOKEN;
|
|
||||||
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (originalEnv.ADMIN_TOKEN === undefined) delete process.env.ADMIN_TOKEN;
|
|
||||||
else process.env.ADMIN_TOKEN = originalEnv.ADMIN_TOKEN;
|
|
||||||
if (originalEnv.NEXT_PUBLIC_ADMIN_TOKEN === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = originalEnv.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits no warning when both are unset", () => {
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits no warning when both are set (matched pair, the happy path)", () => {
|
|
||||||
process.env.ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns when ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not", () => {
|
|
||||||
process.env.ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
// Exact-string assertion — substring would also pass when the
|
|
||||||
// function's branch logic is broken (e.g. emits both messages, or
|
|
||||||
// emits the wrong one). Pin the exact message that operators will
|
|
||||||
// see in their dev console so regressions are visible.
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
|
|
||||||
"canvas will 401 against workspace-server because the bearer header " +
|
|
||||||
"is never attached. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns when NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not", () => {
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
|
|
||||||
"workspace-server will reject the bearer because no AdminAuth gate " +
|
|
||||||
"is configured. Set both to the same value, or unset both.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Empty string in process.env is the JS-side representation of `KEY=`
|
|
||||||
// (no value) in a .env file. Treating "" as unset makes the pair
|
|
||||||
// invariant symmetric: `KEY=` and `unset KEY` produce the same
|
|
||||||
// verdict. Without this branch, an operator who comments out the
|
|
||||||
// value but leaves the line would get a false-positive warning.
|
|
||||||
it("treats empty string as unset (so KEY= and unset KEY are equivalent)", () => {
|
|
||||||
process.env.ADMIN_TOKEN = "";
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns when ADMIN_TOKEN is set and NEXT_PUBLIC_ADMIN_TOKEN is empty string", () => {
|
|
||||||
process.env.ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
|
|
||||||
checkAdminTokenPair();
|
|
||||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
// First branch — server set, client unset.
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
|
|
||||||
// Tests for platformAuthHeaders — the shared helper extracted in #178
|
|
||||||
// to consolidate the bearer-token-attach + tenant-slug-attach pattern
|
|
||||||
// that was previously duplicated across 7 raw-fetch callsites in the
|
|
||||||
// canvas (uploads + 5 Attachment* components + the api.ts request()
|
|
||||||
// function).
|
|
||||||
//
|
|
||||||
// What we pin here:
|
|
||||||
// - Returns a fresh object each call (so callers can mutate without
|
|
||||||
// leaking into each other).
|
|
||||||
// - Empty result on a non-tenant host with no admin token (the
|
|
||||||
// localhost / self-hosted shape).
|
|
||||||
// - Bearer attached when NEXT_PUBLIC_ADMIN_TOKEN is set.
|
|
||||||
// - X-Molecule-Org-Slug attached when window.location.hostname is a
|
|
||||||
// tenant subdomain (<slug>.moleculesai.app).
|
|
||||||
// - Both attached when both apply (the production SaaS shape).
|
|
||||||
//
|
|
||||||
// Why jsdom: getTenantSlug() reads window.location.hostname. Node-only
|
|
||||||
// environment yields no window and getTenantSlug returns null
|
|
||||||
// unconditionally — wouldn't exercise the slug branch.
|
|
||||||
|
|
||||||
import { platformAuthHeaders } from "../api";
|
|
||||||
|
|
||||||
describe("platformAuthHeaders", () => {
|
|
||||||
let originalAdminToken: string | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalAdminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (originalAdminToken === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = originalAdminToken;
|
|
||||||
// jsdom resets hostname between tests via the @vitest-environment
|
|
||||||
// pragma's per-test isolation. No explicit reset needed.
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty object on a non-tenant host with no admin token", () => {
|
|
||||||
// jsdom default hostname is "localhost" — not a tenant slug, so
|
|
||||||
// getTenantSlug() returns null and no X-Molecule-Org-Slug is added.
|
|
||||||
const headers = platformAuthHeaders();
|
|
||||||
expect(headers).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches Authorization when NEXT_PUBLIC_ADMIN_TOKEN is set", () => {
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
|
|
||||||
const headers = platformAuthHeaders();
|
|
||||||
expect(headers).toEqual({ Authorization: "Bearer local-dev-admin" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT attach Authorization when NEXT_PUBLIC_ADMIN_TOKEN is empty string", () => {
|
|
||||||
// Empty-string env is the JS-side shape of `KEY=` in .env.
|
|
||||||
// Treating it as unset matches the matched-pair guard in
|
|
||||||
// next.config.ts (admin-token-pair.test.ts) — symmetric semantics.
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
|
|
||||||
const headers = platformAuthHeaders();
|
|
||||||
expect(headers).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches X-Molecule-Org-Slug on a tenant subdomain", () => {
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
value: { hostname: "reno-stars.moleculesai.app" },
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
const headers = platformAuthHeaders();
|
|
||||||
expect(headers).toEqual({ "X-Molecule-Org-Slug": "reno-stars" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches both when both apply (production SaaS shape)", () => {
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
value: { hostname: "reno-stars.moleculesai.app" },
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tenant-bearer";
|
|
||||||
const headers = platformAuthHeaders();
|
|
||||||
// Pin exact-equality on the full shape — substring/contains
|
|
||||||
// assertions would also pass for an extra-header bug.
|
|
||||||
expect(headers).toEqual({
|
|
||||||
"X-Molecule-Org-Slug": "reno-stars",
|
|
||||||
Authorization: "Bearer tenant-bearer",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a fresh object each call (callers can mutate safely)", () => {
|
|
||||||
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tok";
|
|
||||||
const a = platformAuthHeaders();
|
|
||||||
const b = platformAuthHeaders();
|
|
||||||
expect(a).not.toBe(b); // distinct refs
|
|
||||||
expect(a).toEqual(b); // same content
|
|
||||||
a["Content-Type"] = "application/json";
|
|
||||||
// Mutation on `a` does not leak into `b`.
|
|
||||||
expect(b["Content-Type"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -21,45 +21,6 @@ export interface RequestOptions {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the platform auth header set used by every authenticated fetch
|
|
||||||
* from the canvas. Returns a fresh object so callers can mutate (e.g.
|
|
||||||
* append `Content-Type` for JSON requests, omit it for FormData).
|
|
||||||
*
|
|
||||||
* SaaS cross-origin shape:
|
|
||||||
* - `X-Molecule-Org-Slug` — derived from `window.location.hostname`
|
|
||||||
* by `getTenantSlug()`. Control plane uses it for fly-replay
|
|
||||||
* routing. Empty on localhost / non-tenant hosts — safe to omit.
|
|
||||||
* - `Authorization: Bearer <token>` — `NEXT_PUBLIC_ADMIN_TOKEN` baked
|
|
||||||
* into the canvas build (see canvas/Dockerfile L8/L11). Required by
|
|
||||||
* the workspace-server when `ADMIN_TOKEN` is set on the server side
|
|
||||||
* (Tier-2b AdminAuth gate, wsauth_middleware.go ~L245). Empty when
|
|
||||||
* no admin token was provisioned — the Tier-1 session-cookie path
|
|
||||||
* handles that case via `credentials:"include"`.
|
|
||||||
*
|
|
||||||
* Why a shared helper: the two-line "read env, attach bearer; read
|
|
||||||
* slug, attach header" pattern was duplicated across `request()` and
|
|
||||||
* 7 raw-fetch callsites (chat uploads/download + 5 Attachment*
|
|
||||||
* components) before this consolidation. A new poller or raw fetch
|
|
||||||
* that forgets one of the two headers silently 401s against
|
|
||||||
* workspace-server when ADMIN_TOKEN is set — the exact bug shape
|
|
||||||
* called out in #178 / closes the post-#176 self-review gap.
|
|
||||||
*
|
|
||||||
* Callers that want JSON Content-Type should spread this and add it
|
|
||||||
* themselves; FormData callers should NOT add Content-Type (the
|
|
||||||
* browser sets the multipart boundary). Centralizing the auth pair
|
|
||||||
* but leaving Content-Type up to the caller is the minimum viable
|
|
||||||
* shared shape.
|
|
||||||
*/
|
|
||||||
export function platformAuthHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const slug = getTenantSlug();
|
|
||||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
|
||||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
|
||||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@ -67,16 +28,17 @@ async function request<T>(
|
|||||||
retryCount = 0,
|
retryCount = 0,
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// JSON-bodied request — Content-Type is JSON. Auth pair comes from
|
// SaaS cross-origin shape:
|
||||||
// the shared helper; see its doc comment for the SaaS-shape rationale.
|
// - X-Molecule-Org-Slug: derived from window.location.hostname by
|
||||||
const headers: Record<string, string> = {
|
// getTenantSlug(). Control plane uses it for fly-replay routing.
|
||||||
"Content-Type": "application/json",
|
// Empty on localhost / non-tenant hosts — safe to omit.
|
||||||
...platformAuthHeaders(),
|
// - credentials:"include": sends the session cookie cross-origin.
|
||||||
};
|
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
|
||||||
// Re-read slug locally for the 401 handler below — `headers` already
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
// has it, but the 401 branch needs the bare value to gate the
|
|
||||||
// session-probe + redirect logic on tenant context.
|
|
||||||
const slug = getTenantSlug();
|
const slug = getTenantSlug();
|
||||||
|
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||||
|
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||||
|
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||||
|
|
||||||
const res = await fetch(`${PLATFORM_URL}${path}`, {
|
const res = await fetch(`${PLATFORM_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@ -835,180 +835,3 @@ describe("handleCanvasEvent – unknown event", () => {
|
|||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Screen-reader live announcements
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("handleCanvasEvent – liveAnnouncement", () => {
|
|
||||||
it("announces WORKSPACE_ONLINE with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Alpha" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Alpha is now online");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_OFFLINE with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Beta" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Beta is now offline");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_PAUSED with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Gamma" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Gamma has been paused");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_DEGRADED with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Delta" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "WORKSPACE_DEGRADED",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
payload: { sample_error: "connection timeout" },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Delta is degraded");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => {
|
|
||||||
const { get, set, state } = makeStore([]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "WORKSPACE_PROVISIONING",
|
|
||||||
workspace_id: "ws-new",
|
|
||||||
payload: { name: "NewBot" },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("NewBot is provisioning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => {
|
|
||||||
const { get, set, state } = makeStore([]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "WORKSPACE_PROVISIONING",
|
|
||||||
workspace_id: "ws-new",
|
|
||||||
payload: {},
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("New Workspace is provisioning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_REMOVED with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Gamma" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Gamma was removed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces WORKSPACE_PROVISION_FAILED with node name", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Delta" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "WORKSPACE_PROVISION_FAILED",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
payload: { error: "docker pull failed" },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement).toBe("Delta provisioning failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not announce for TASK_UPDATED", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Alpha" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "TASK_UPDATED",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
payload: { current_task: "building release", active_tasks: 1 },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
// TASK_UPDATED is noisy (every heartbeat); it should not announce
|
|
||||||
expect(state.liveAnnouncement ?? "").toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not announce for AGENT_MESSAGE", () => {
|
|
||||||
const node = makeNode("ws-1", { name: "Alpha" });
|
|
||||||
const { get, set, state } = makeStore([node]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "AGENT_MESSAGE",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
payload: { message: "hello from the agent" },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.liveAnnouncement ?? "").toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses payload name for ONLINE when node not found in store", () => {
|
|
||||||
const { get, set, state } = makeStore([]);
|
|
||||||
|
|
||||||
handleCanvasEvent(
|
|
||||||
makeMsg({
|
|
||||||
event: "WORKSPACE_ONLINE",
|
|
||||||
workspace_id: "ws-1",
|
|
||||||
payload: { name: "FromPayload" },
|
|
||||||
}),
|
|
||||||
get,
|
|
||||||
set
|
|
||||||
);
|
|
||||||
|
|
||||||
// ONLINE when node doesn't exist just buffers _pendingOnline;
|
|
||||||
// no announcement should be set
|
|
||||||
expect(state.liveAnnouncement ?? "").toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1181,46 +1181,3 @@ describe("batchNest", () => {
|
|||||||
expect(nestPatches).toHaveLength(1);
|
expect(nestPatches).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- moveNode ----------
|
|
||||||
|
|
||||||
describe("moveNode", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const mock = global.fetch as ReturnType<typeof vi.fn>;
|
|
||||||
mock.mockImplementation(() =>
|
|
||||||
Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response),
|
|
||||||
);
|
|
||||||
mock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the node's position by the given delta", () => {
|
|
||||||
useCanvasStore.getState().hydrate([
|
|
||||||
makeWS({ id: "n1", name: "Node 1", x: 100, y: 200 }),
|
|
||||||
]);
|
|
||||||
useCanvasStore.getState().selectNode("n1");
|
|
||||||
useCanvasStore.getState().moveNode("n1", 10, -50);
|
|
||||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === "n1")!;
|
|
||||||
expect(node.position).toEqual({ x: 110, y: 150 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when the node does not exist", () => {
|
|
||||||
useCanvasStore.getState().hydrate([makeWS({ id: "n1", name: "Node 1", x: 0, y: 0 })]);
|
|
||||||
expect(() => useCanvasStore.getState().moveNode("nonexistent", 10, 10)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls savePosition with the new absolute coordinates", async () => {
|
|
||||||
useCanvasStore.getState().hydrate([makeWS({ id: "n1", name: "Node 1", x: 100, y: 200 })]);
|
|
||||||
useCanvasStore.getState().selectNode("n1");
|
|
||||||
const mock = global.fetch as ReturnType<typeof vi.fn>;
|
|
||||||
useCanvasStore.getState().moveNode("n1", 10, 20);
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(mock).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("/workspaces/n1"),
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ x: 110, y: 220 }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -80,7 +80,6 @@ export function handleCanvasEvent(
|
|||||||
switch (msg.event) {
|
switch (msg.event) {
|
||||||
case "WORKSPACE_ONLINE": {
|
case "WORKSPACE_ONLINE": {
|
||||||
const existing = nodes.find((n) => n.id === msg.workspace_id);
|
const existing = nodes.find((n) => n.id === msg.workspace_id);
|
||||||
const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace";
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// PROVISIONING event hasn't been applied yet (WS reorder or
|
// PROVISIONING event hasn't been applied yet (WS reorder or
|
||||||
// this tab joined mid-deploy). Buffer so the later PROVISIONING
|
// this tab joined mid-deploy). Buffer so the later PROVISIONING
|
||||||
@ -106,7 +105,6 @@ export function handleCanvasEvent(
|
|||||||
? { ...n, data: { ...n.data, status: "online" } }
|
? { ...n, data: { ...n.data, status: "online" } }
|
||||||
: n,
|
: n,
|
||||||
),
|
),
|
||||||
liveAnnouncement: `${nodeName} is now online`,
|
|
||||||
});
|
});
|
||||||
// Remove the laser class after its keyframe ends so the edge
|
// Remove the laser class after its keyframe ends so the edge
|
||||||
// settles into the app's default solid styling. Fire-and-forget.
|
// settles into the app's default solid styling. Fire-and-forget.
|
||||||
@ -125,36 +123,28 @@ export function handleCanvasEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "WORKSPACE_OFFLINE": {
|
case "WORKSPACE_OFFLINE": {
|
||||||
const offlineNode = nodes.find((n) => n.id === msg.workspace_id);
|
|
||||||
const offlineName = offlineNode?.data.name ?? "Workspace";
|
|
||||||
set({
|
set({
|
||||||
nodes: nodes.map((n) =>
|
nodes: nodes.map((n) =>
|
||||||
n.id === msg.workspace_id
|
n.id === msg.workspace_id
|
||||||
? { ...n, data: { ...n.data, status: "offline" } }
|
? { ...n, data: { ...n.data, status: "offline" } }
|
||||||
: n
|
: n
|
||||||
),
|
),
|
||||||
liveAnnouncement: `${offlineName} is now offline`,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "WORKSPACE_PAUSED": {
|
case "WORKSPACE_PAUSED": {
|
||||||
const pausedNode = nodes.find((n) => n.id === msg.workspace_id);
|
|
||||||
const pausedName = pausedNode?.data.name ?? "Workspace";
|
|
||||||
set({
|
set({
|
||||||
nodes: nodes.map((n) =>
|
nodes: nodes.map((n) =>
|
||||||
n.id === msg.workspace_id
|
n.id === msg.workspace_id
|
||||||
? { ...n, data: { ...n.data, status: "paused", currentTask: "" } }
|
? { ...n, data: { ...n.data, status: "paused", currentTask: "" } }
|
||||||
: n
|
: n
|
||||||
),
|
),
|
||||||
liveAnnouncement: `${pausedName} has been paused`,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "WORKSPACE_DEGRADED": {
|
case "WORKSPACE_DEGRADED": {
|
||||||
const degradedNode = nodes.find((n) => n.id === msg.workspace_id);
|
|
||||||
const degradedName = degradedNode?.data.name ?? "Workspace";
|
|
||||||
set({
|
set({
|
||||||
nodes: nodes.map((n) =>
|
nodes: nodes.map((n) =>
|
||||||
n.id === msg.workspace_id
|
n.id === msg.workspace_id
|
||||||
@ -170,7 +160,6 @@ export function handleCanvasEvent(
|
|||||||
}
|
}
|
||||||
: n
|
: n
|
||||||
),
|
),
|
||||||
liveAnnouncement: `${degradedName} is degraded`,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -241,7 +230,6 @@ export function handleCanvasEvent(
|
|||||||
// removed per demo feedback. A2A edges (showA2AEdges) still
|
// removed per demo feedback. A2A edges (showA2AEdges) still
|
||||||
// render when enabled — those represent runtime traffic,
|
// render when enabled — those represent runtime traffic,
|
||||||
// which nesting doesn't express.
|
// which nesting doesn't express.
|
||||||
const newNodeName = (msg.payload.name as string) ?? "New Workspace";
|
|
||||||
set({
|
set({
|
||||||
nodes: [
|
nodes: [
|
||||||
...nodes,
|
...nodes,
|
||||||
@ -256,7 +244,7 @@ export function handleCanvasEvent(
|
|||||||
...(parentId ? { parentId } : {}),
|
...(parentId ? { parentId } : {}),
|
||||||
className: "mol-deploy-spawn",
|
className: "mol-deploy-spawn",
|
||||||
data: {
|
data: {
|
||||||
name: newNodeName,
|
name: (msg.payload.name as string) ?? "New Workspace",
|
||||||
status: "provisioning",
|
status: "provisioning",
|
||||||
tier: (msg.payload.tier as number) ?? 1,
|
tier: (msg.payload.tier as number) ?? 1,
|
||||||
agentCard: null,
|
agentCard: null,
|
||||||
@ -273,7 +261,6 @@ export function handleCanvasEvent(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
liveAnnouncement: `${newNodeName} is provisioning`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Grow the parent to fit the just-landed child. DEBOUNCED
|
// Grow the parent to fit the just-landed child. DEBOUNCED
|
||||||
@ -358,7 +345,6 @@ export function handleCanvasEvent(
|
|||||||
|
|
||||||
case "WORKSPACE_REMOVED": {
|
case "WORKSPACE_REMOVED": {
|
||||||
const removedNode = nodes.find((n) => n.id === msg.workspace_id);
|
const removedNode = nodes.find((n) => n.id === msg.workspace_id);
|
||||||
const removedName = removedNode?.data.name ?? "Workspace";
|
|
||||||
const parentOfRemoved = removedNode?.data.parentId ?? null;
|
const parentOfRemoved = removedNode?.data.parentId ?? null;
|
||||||
set({
|
set({
|
||||||
nodes: nodes
|
nodes: nodes
|
||||||
@ -377,7 +363,6 @@ export function handleCanvasEvent(
|
|||||||
e.source !== msg.workspace_id && e.target !== msg.workspace_id
|
e.source !== msg.workspace_id && e.target !== msg.workspace_id
|
||||||
),
|
),
|
||||||
selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId,
|
selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId,
|
||||||
liveAnnouncement: `${removedName} was removed`,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -460,8 +445,6 @@ export function handleCanvasEvent(
|
|||||||
|
|
||||||
case "WORKSPACE_PROVISION_FAILED": {
|
case "WORKSPACE_PROVISION_FAILED": {
|
||||||
const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error";
|
const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error";
|
||||||
const failedNode = nodes.find((n) => n.id === msg.workspace_id);
|
|
||||||
const failedName = failedNode?.data.name ?? "Workspace";
|
|
||||||
set({
|
set({
|
||||||
nodes: nodes.map((n) =>
|
nodes: nodes.map((n) =>
|
||||||
n.id === msg.workspace_id
|
n.id === msg.workspace_id
|
||||||
@ -475,7 +458,6 @@ export function handleCanvasEvent(
|
|||||||
}
|
}
|
||||||
: n
|
: n
|
||||||
),
|
),
|
||||||
liveAnnouncement: `${failedName} provisioning failed`,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,13 +165,6 @@ interface CanvasState {
|
|||||||
* this so a drag that pushed a child past the parent edge commits
|
* this so a drag that pushed a child past the parent edge commits
|
||||||
* the parent grow on release (commit-on-release pattern). */
|
* the parent grow on release (commit-on-release pattern). */
|
||||||
growParentsToFitChildren: () => void;
|
growParentsToFitChildren: () => void;
|
||||||
/** Move a selected node by (dx, dy) in canvas space. Used by keyboard
|
|
||||||
* arrow-key shortcuts so keyboard users can reposition nodes without a
|
|
||||||
* mouse. Persists the new position to the backend and skips the
|
|
||||||
* grow-parents pass that onNodesChange runs on every drag tick
|
|
||||||
* (avoids the "edge-chase" flicker that commit-on-release is meant to
|
|
||||||
* prevent). */
|
|
||||||
moveNode: (nodeId: string, dx: number, dy: number) => void;
|
|
||||||
/** Re-layout a parent's children to the default 2-column grid. Used
|
/** Re-layout a parent's children to the default 2-column grid. Used
|
||||||
* by the "Arrange children" context-menu command so users can rescue
|
* by the "Arrange children" context-menu command so users can rescue
|
||||||
* out-of-bounds children on demand — topology no longer does it
|
* out-of-bounds children on demand — topology no longer does it
|
||||||
@ -232,11 +225,6 @@ interface CanvasState {
|
|||||||
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
|
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
|
||||||
showA2AEdges: boolean;
|
showA2AEdges: boolean;
|
||||||
setShowA2AEdges: (show: boolean) => void;
|
setShowA2AEdges: (show: boolean) => void;
|
||||||
/** Screen-reader announcement text. Set by handleCanvasEvent on significant
|
|
||||||
* status changes; consumed and cleared by the aria-live region in Canvas.tsx
|
|
||||||
* so the same announcement doesn't re-fire on re-render. */
|
|
||||||
liveAnnouncement: string;
|
|
||||||
setLiveAnnouncement: (msg: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||||
@ -333,8 +321,6 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
|||||||
localStorage.setItem("molecule:show-a2a-edges", String(show));
|
localStorage.setItem("molecule:show-a2a-edges", String(show));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
liveAnnouncement: "",
|
|
||||||
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
|
|
||||||
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
|
||||||
@ -1039,19 +1025,6 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
moveNode: (nodeId, dx, dy) => {
|
|
||||||
const node = get().nodes.find((n) => n.id === nodeId);
|
|
||||||
if (!node) return;
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.map((n) =>
|
|
||||||
n.id === nodeId
|
|
||||||
? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
|
|
||||||
: n,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
void get().savePosition(nodeId, node.position.x + dx, node.position.y + dy);
|
|
||||||
},
|
|
||||||
|
|
||||||
savePosition: async (nodeId: string, x: number, y: number) => {
|
savePosition: async (nodeId: string, x: number, y: number) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/workspaces/${nodeId}`, { x, y });
|
await api.patch(`/workspaces/${nodeId}`, { x, y });
|
||||||
|
|||||||
@ -7,22 +7,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
exclude: ['e2e/**', 'node_modules/**', '**/dist/**'],
|
exclude: ['e2e/**', 'node_modules/**', '**/dist/**'],
|
||||||
// Issue #22 / vitest pool investigation:
|
|
||||||
//
|
|
||||||
// The forks pool spawns one Node.js worker per concurrent slot.
|
|
||||||
// Each jsdom-environment worker bootstraps a full DOM (~30-50 MB resident
|
|
||||||
// set) at cold-start. With the default maxWorkers derived from CPU
|
|
||||||
// count, multiple jsdom workers can start simultaneously, exhausting
|
|
||||||
// memory on the 2-CPU Gitea Actions runner and causing pool workers to
|
|
||||||
// fail to respond with "[vitest-pool]: Timeout starting … runner."
|
|
||||||
//
|
|
||||||
// Fix: cap maxWorkers at 1 so only one worker is alive at any time.
|
|
||||||
// Tests still run in parallel within that single worker's process (via
|
|
||||||
// node's EventLoop) — this is the same parallelism as the `threads`
|
|
||||||
// pool but without the per-worker jsdom cold-start overhead. 51 test
|
|
||||||
// files that previously took 5070 s with 5 failures now run
|
|
||||||
// sequentially through one worker, eliminating the memory spike.
|
|
||||||
maxWorkers: 1,
|
|
||||||
// CI-conditional test timeout (issue #96).
|
// CI-conditional test timeout (issue #96).
|
||||||
//
|
//
|
||||||
// Vitest's 5000ms default is too tight for the first test in any
|
// Vitest's 5000ms default is too tight for the first test in any
|
||||||
|
|||||||
@ -119,7 +119,7 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: molecule-core-net
|
name: molecule-monorepo-net
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
# Include infra services (Temporal, Langfuse) so `docker compose up` starts the full stack.
|
|
||||||
include:
|
|
||||||
- docker-compose.infra.yml
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# --- Infrastructure ---
|
# --- Infrastructure ---
|
||||||
postgres:
|
postgres:
|
||||||
@ -16,8 +12,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@ -44,7 +39,7 @@ services:
|
|||||||
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
|
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
|
||||||
fi
|
fi
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@ -54,8 +49,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@ -72,7 +66,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- clickhousedata:/var/lib/clickhouse
|
- clickhousedata:/var/lib/clickhouse
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@ -101,7 +95,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@ -132,10 +126,6 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
PORT: "${PLATFORM_PORT:-8080}"
|
PORT: "${PLATFORM_PORT:-8080}"
|
||||||
PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}"
|
PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}"
|
||||||
# Container network namespace is already isolated; "all interfaces"
|
|
||||||
# inside the container = the bridge interface only. The fail-open
|
|
||||||
# default (127.0.0.1) would block host-to-container access.
|
|
||||||
BIND_ADDR: "${BIND_ADDR:-0.0.0.0}"
|
|
||||||
# Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth
|
# Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth
|
||||||
# middleware fail-open path activates when ADMIN_TOKEN is unset —
|
# middleware fail-open path activates when ADMIN_TOKEN is unset —
|
||||||
# otherwise the canvas (which runs without a bearer in pure local
|
# otherwise the canvas (which runs without a bearer in pure local
|
||||||
@ -205,28 +195,12 @@ services:
|
|||||||
# App private key — read-only bind-mount. The host-side path is
|
# App private key — read-only bind-mount. The host-side path is
|
||||||
# gitignored per .gitignore rules (/.secrets/ + *.pem).
|
# gitignored per .gitignore rules (/.secrets/ + *.pem).
|
||||||
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro
|
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro
|
||||||
# Per-role persona credentials (molecule-core#242 local surface).
|
|
||||||
# Sourced at workspace creation time by org_import.go::loadPersonaEnvFile
|
|
||||||
# when a workspace.yaml carries `role: <name>`. The host-side dir is
|
|
||||||
# populated by the operator-host bootstrap kit (28 dev-tree personas);
|
|
||||||
# /etc/molecule-bootstrap/personas is the in-container path the
|
|
||||||
# platform expects (matches the prod tenant-EC2 path so the same code
|
|
||||||
# works in both modes).
|
|
||||||
#
|
|
||||||
# Read-only mount — workspace-server only reads, never writes here.
|
|
||||||
# If the host dir is empty/missing the platform's loadPersonaEnvFile
|
|
||||||
# silently no-ops per its existing semantics, so this mount is safe
|
|
||||||
# even on a fresh machine that hasn't run the bootstrap kit yet.
|
|
||||||
- ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}:/etc/molecule-bootstrap/personas:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
|
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# Plain GET — `--spider` would issue HEAD, which returns 404 because
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
|
||||||
# /health is registered as GET only.
|
|
||||||
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
|
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@ -262,9 +236,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}"
|
- "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}"
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@ -295,7 +269,7 @@ services:
|
|||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule}
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule}
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"]
|
||||||
@ -320,7 +294,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ollamadata:/root/.ollama
|
- ollamadata:/root/.ollama
|
||||||
networks:
|
networks:
|
||||||
- molecule-core-net
|
- molecule-monorepo-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "ollama list || exit 1"]
|
test: ["CMD-SHELL", "ollama list || exit 1"]
|
||||||
@ -330,8 +304,8 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
molecule-core-net:
|
molecule-monorepo-net:
|
||||||
name: molecule-core-net
|
name: molecule-monorepo-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user