Compare commits

..

4 Commits

Author SHA1 Message Date
fullstack-engineer 6348522baa fix(a2a): handle string-form errors in delegate_task
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Failing after 11s
audit-force-merge / audit (pull_request) Successful in 54s
The A2A proxy can return three error shapes:
  {"error": "plain string"}
  {"error": {"message": "...", "code": ...}}
  {"error": {"message": {"nested": "object"}}}   ← value at .message is a string

builtin_tools/a2a_tools.py:72 called data["error"].get("message")
without guarding against error being a string, which raised:
  AttributeError: 'str' object has no attribute 'get'

This broke every delegation attempt through the legacy a2a_tools path
(the LangChain-wrapped version used by adapter templates). The
SSOT parser a2a_response.py already handled string errors; the
legacy inline sniffer in a2a_tools.py did not.

Fix: branch on isinstance(err, dict/str/other) before calling .get().

Also update both publish-workflow files to remove the dead
`staging` branch trigger — trunk-based migration (PR #109,
2026-05-08) removed the staging branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:09:33 +00:00
integration-tester 97fcb32840 chore: restore manifest.json after trigger test
publish-workspace-server-image / build-and-push (push) Failing after 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
2026-05-10 08:52:45 +00:00
integration-tester 19b95243d2 chore: trigger publish workflow [Integration Tester 2026-05-10T08:45Z]
publish-workspace-server-image / build-and-push (push) Failing after 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
2026-05-10 08:52:14 +00:00
integration-tester e5622e0dae chore: staging trigger commit from Integration Tester
Secret scan / Scan diff for credential-shaped strings (push) Successful in 24s
2026-05-10 08:31:19 +00:00
148 changed files with 1245 additions and 6894 deletions
@@ -1,100 +0,0 @@
name: publish-runtime-autobump
# Auto-bump-on-workspace-edit half of the publish pipeline.
#
# Why this file exists (issue #351):
# Gitea Actions does not correctly disambiguate `paths:` from `tags:`
# when both are bundled under a single `on.push` key. The result is
# that tag pushes get filtered out and `publish-runtime.yml` never
# fires — `action_run` rows: 0. This was unnoticed pre-2026-05-11
# because PYPI_TOKEN was absent (publishes would have failed anyway).
#
# Split design:
# - publish-runtime.yml : on.push.tags only (the publisher)
# - publish-runtime-autobump.yml: on.push.branches+paths (this file — the version-bumper)
#
# This file computes the next version from PyPI's latest, pushes a
# `runtime-v$VERSION` tag, and exits. The tag push then triggers
# publish-runtime.yml via its tags-only trigger.
#
# Concurrency: shares the `publish-runtime` group with publish-runtime.yml
# so concurrent workspace pushes serialize at the bump step. Without
# this, two pushes minutes apart could both read PyPI latest=0.1.129
# and try to tag 0.1.130 simultaneously, only one of which would land.
on:
push:
branches:
- main
- staging
paths:
- "workspace/**"
permissions:
contents: write # required to push tags back
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
autobump-and-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch full tag list so the bump logic can sanity-check against
# what's already in this repo (catches collision with prior
# manual tag pushes).
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Compute next version from PyPI latest
id: bump
run: |
set -eu
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 "PyPI latest=$LATEST -> next=$VERSION"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
exit 1
fi
if git tag --list | grep -qx "runtime-v$VERSION"; then
echo "::error::tag runtime-v$VERSION already exists in this repo. Manual intervention required (PyPI and Gitea tag history are out of sync)."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Push runtime-v$VERSION tag
env:
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
VERSION: ${{ steps.bump.outputs.version }}
GITEA_URL: https://git.moleculesai.app
run: |
set -eu
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::error::DISPATCH_TOKEN secret is not set — needed to push the tag back to molecule-core."
exit 1
fi
git config user.name "publish-runtime autobump"
git config user.email "publish-runtime@moleculesai.app"
git tag -a "runtime-v$VERSION" \
-m "Auto-bump on workspace/** edit on $GITHUB_REF" \
-m "Triggered by: $GITHUB_REF @ $GITHUB_SHA" \
-m "publish-runtime.yml will pick up this tag and upload to PyPI"
# Push via DISPATCH_TOKEN (a Gitea PAT). Using the bot identity
# ensures the resulting tag-push event is dispatched to
# publish-runtime.yml; act_runner's default GITHUB_TOKEN cannot
# trigger downstream workflows.
git remote set-url origin "${GITEA_URL#https://}"
git remote set-url origin "https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/molecule-ai/molecule-core.git"
git push origin "runtime-v$VERSION"
echo "✓ pushed runtime-v$VERSION — publish-runtime.yml should fire next"
+15 -41
View File
@@ -12,24 +12,7 @@ name: publish-runtime
# - 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)
#
# 2026-05-10 (issue #348): originally restored `staging`/`main` branch +
# `workspace/**` path-filter trigger in PR #349.
#
# 2026-05-11 (issue #351): REVERTED the branches+paths trigger from THIS
# file. Bundling `paths` with `tags` under a single `on.push` key caused
# Gitea Actions to never dispatch the workflow for tag-push events (0
# runs in `action_run` for workflow_id='publish-runtime.yml' since the
# port, including the runtime-v1.0.0 tag — which is why PyPI is still at
# 0.1.129 despite a v1.0.0 Gitea tag existing).
#
# The auto-bump-on-workspace-edit trigger now lives in
# `.gitea/workflows/publish-runtime-autobump.yml`. That file computes the
# next version from PyPI's latest and pushes a `runtime-v$VERSION` tag,
# which THIS file then picks up via the tags-only trigger below.
#
# This decoupling means Gitea's path-vs-tag evaluator never has to
# disambiguate — each file has a single unambiguous trigger shape.
# - 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.
@@ -43,17 +26,11 @@ on:
tags:
- "runtime-v*"
workflow_dispatch:
# 2026-05-11 (root cause of #351 / 0 runs ever):
# Gitea 1.22.6's workflow parser rejects `workflow_dispatch.inputs.version`
# with "unknown on type" — it mis-treats the inputs sub-keys as top-level
# `on:` event types. Log line:
# actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
# "publish-runtime.yml": unknown on type: map["version": {...}]
# That `[W] ignore invalid workflow` is silent UX — the workflow never
# registers, so it never fires for ANY event (push.tags included).
# Removing the inputs block restores parsing. Manual dispatch from the
# Gitea UI now triggers the PyPI auto-bump fallback in `Derive version`
# below (no `inputs.version` to read).
inputs:
version:
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
required: true
type: string
permissions:
contents: read
@@ -78,15 +55,20 @@ jobs:
python-version: "3.11"
cache: pip
- name: Derive version (tag or PyPI auto-bump)
- name: Derive version (tag, manual input, or PyPI auto-bump)
id: version
run: |
if echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
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
# workflow_dispatch path (no inputs supported on Gitea 1.22.6) or
# any other non-tag trigger: derive from PyPI latest + patch bump.
# 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)
@@ -139,14 +121,6 @@ jobs:
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
- name: Publish to PyPI
# working-directory matches the preceding Build/Verify steps. Without
# this, twine runs from the default workspace checkout dir where
# `dist/` doesn't exist and fails with:
# ERROR InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'
# Caught on the first-ever successful dispatch of this workflow
# (run 5097, 2026-05-11 02:08Z) — every other step in the publish
# job already had this working-directory; Publish was missing it.
working-directory: ${{ runner.temp }}/runtime-build
env:
# PYPI_TOKEN: repository secret scoped to molecule-ai-workspace-runtime.
# Set via: Settings → Actions → Variables and Secrets → New Secret.
@@ -32,11 +32,9 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
@@ -59,25 +57,6 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible (e.g. permission change, daemon restart, or group-membership
# drift) rather than silently continuing to step 2 where `docker build`
# fails deep in the process with a cryptic ECR auth error that doesn't
# surface the root cause. Also reports the daemon version so operator
# can correlate with runner host logs.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
#
# Why: workspace-template-* repos on Gitea are private. The pre-fix
+1 -1
View File
@@ -365,7 +365,7 @@ jobs:
cache: pip
cache-dependency-path: workspace/requirements.txt
- if: needs.changes.outputs.python == 'true'
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
# Coverage flags + fail-under floor moved into workspace/pytest.ini
# (issue #1817) so local `pytest` and CI use identical config.
- if: needs.changes.outputs.python == 'true'
@@ -54,22 +54,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
- name: Compute tags
id: tags
shell: bash
+1 -1
View File
@@ -180,7 +180,7 @@ jobs:
# environment pypi-publish. The action mints a short-lived OIDC
# token and exchanges it for a PyPI upload credential — no static
# API token in this repo's secrets.
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ${{ runner.temp }}/runtime-build/dist/
@@ -0,0 +1,262 @@
name: publish-workspace-server-image
# Builds and pushes Docker images to GHCR on staging or main pushes.
# EC2 tenant instances pull the tenant image from GHCR.
#
# Branch / tag policy (see Compute tags step for the per-branch logic):
#
# staging push → builds image, tags :staging-<sha> + :staging-latest.
# staging-CP pins TENANT_IMAGE=:staging-latest, so it
# picks up staging-branch code automatically. This is
# what makes staging-CP actually test staging-branch
# code instead of "yesterday's main" — pre-fix, this
# workflow only ran on main, so staging tenants
# silently served stale code (#2308 fix RFC #2312
# landed on staging but never reached tenants because
# staging→main was wedged on path-filter parity bugs).
#
# main push → builds image, tags :staging-<sha> + :staging-latest
# (same as before). canary-verify.yml retags
# :staging-<sha> → :latest after canary tenants
# green-light the digest. The :staging-latest retag
# on main push is intentional: when main lands AFTER a
# staging push, staging-CP gets the post-promote code
# (which equals what it had + any merge resolution),
# so the canary-on-staging-CP step still runs against
# the prod-bound digest.
#
# In the steady state both branches refresh :staging-latest; the
# semantic is "most recent staging-or-main build of tenant code."
# Drift between the two is bounded by the staging→main auto-promote
# cadence and is corrected on the next staging push.
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.github/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different github.ref → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches (the post-promote
# main code equals current staging code in a healthy flow).
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image and keeps the
# canary fleet pin (:staging-<sha>) consistent with what was actually
# tested at canary-verify time.
concurrency:
group: publish-workspace-server-image-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
# plugin was dropped + workspace-server/Dockerfile no longer
# COPYs it.
# ECR auth + buildx setup are now inline in each build step
# below (Task #173, 2026-05-07).
#
# Why moved inline: aws-actions/configure-aws-credentials@v4 +
# aws-actions/amazon-ecr-login@v2 + docker/setup-buildx-action
# all left auth state in places that the actual `docker push`
# couldn't see on Gitea Actions:
# - The actions wrote to a step-scoped DOCKER_CONFIG path
# that didn't survive into subsequent shell steps.
# - Buildx couldn't bridge the runner container ↔
# operator-host docker daemon auth gap (401 on the
# docker-container driver, "no basic auth credentials"
# with the action-driven login).
#
# Doing AWS+ECR auth inline (`aws ecr get-login-password |
# docker login`) in the same shell step as `docker build` +
# `docker push` is the operator-host manual approach, mapped
# 1:1 into CI. Auth state is guaranteed to live in the env that
# `docker push` actually runs from.
#
# Post-suspension target is the operator's ECR org
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*),
# which already hosts platform-tenant + workspace-template-* +
# runner-base images. AWS creds come from the
# AWS_ACCESS_KEY_ID/SECRET secrets bound to the molecule-cp
# IAM user. Closes #161.
- name: Compute tags
id: tags
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Pre-clone manifest deps before docker build (Task #173 fix).
#
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
# Gitea (codex, crewai, deepagents, gemini-cli, langgraph) plus all
# 7 org-template-* repos are private. The pre-fix Dockerfile.tenant
# ran `git clone` inside an in-image stage, which had no auth path
# — every CI build failed with "fatal: could not read Username for
# https://git.moleculesai.app". For weeks, every workspace-server
# rebuild required a manual operator-host push. Now we clone in the
# trusted CI context (where AUTO_SYNC_TOKEN is naturally available)
# and Dockerfile.tenant just COPYs from .tenant-bundle-deps/.
#
# Token shape: AUTO_SYNC_TOKEN is the devops-engineer persona PAT
# (see /etc/molecule-bootstrap/agent-secrets.env). Per saved memory
# `feedback_per_agent_gitea_identity_default`, every CI surface uses
# a per-persona token, never the founder PAT. clone-manifest.sh
# embeds it as basic-auth (oauth2:<token>) for the duration of the
# clones, then strips .git directories — the token never enters
# the resulting image.
#
# Idempotent: if a re-run finds populated dirs, clone-manifest.sh
# skips them; safe to retrigger via path-filter or workflow_dispatch.
- name: Pre-clone manifest deps
env:
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
exit 1
fi
mkdir -p .tenant-bundle-deps
bash scripts/clone-manifest.sh \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
# Sanity-check counts so a silent partial clone fails fast
# instead of producing a half-empty image.
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
# Counts are derived from manifest.json (9 ws / 7 org / 21
# plugins as of 2026-05-07). If manifest.json grows but the
# clone step regresses silently, the find above caps at the
# actual disk state — but clone-manifest.sh's own EXPECTED vs
# CLONED check (line ~95) is the authoritative fail-fast.
# Canary-gated release flow:
# - This step always publishes :staging-<sha> + :staging-latest.
# - On staging push, staging-CP picks up :staging-latest immediately
# (its TENANT_IMAGE pin is :staging-latest) — so staging-branch
# code reaches staging tenants without waiting for main.
# - On main push, canary-verify.yml runs smoke tests against
# canary tenants (which pin :staging-<sha>), and on green retags
# :staging-<sha> → :latest. Prod tenants pull :latest.
# - On red, :latest stays on the prior good digest — prod is safe.
#
# Why :staging-latest is retagged on main push too: when main lands
# after a staging promote, staging-CP gets the post-promote code so
# the canary-on-staging-CP step still runs against the prod-bound
# digest. In a healthy flow the post-promote main code == the
# current staging code, so this is effectively a no-op except for
# the canary fleet pin handoff.
#
# Pre-fix history: this workflow used to only trigger on main. That
# meant staging-CP served "yesterday's main" indefinitely whenever
# staging→main was wedged. The 2026-04-30 dogfooding session
# surfaced this when RFC #2312 (chat upload HTTP-forward) landed on
# staging but staging tenants kept failing chat upload because they
# were running pre-RFC code. Adding the staging trigger above closes
# that gap. Earlier 2026-04-24 incident: a static :staging-<sha> pin
# drifted 10 days behind staging — same class of bug, different
# mechanism. ECR repo molecule-ai/platform created 2026-05-07.
# Build + push platform image with plain `docker` (no buildx).
# GIT_SHA bakes into the Go binary via -ldflags so /buildinfo
# returns it at runtime — see Dockerfile + buildinfo/buildinfo.go.
# The OCI revision label below carries the same value for registry
# tooling; the duplication is intentional.
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
# ECR auth in-step so config.json is populated in the same
# shell env that runs `docker push`. ECR get-login-password
# tokens last 12h, plenty for a single-step build+push.
ECR_REGISTRY="${IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify" \
--tag "${IMAGE_NAME}:${TAG_SHA}" \
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${IMAGE_NAME}:${TAG_SHA}"
docker push "${IMAGE_NAME}:${TAG_LATEST}"
# Canvas uses same-origin fetches. The tenant Go platform
# reverse-proxies /cp/* to the SaaS CP via its CP_UPSTREAM_URL
# env; the tenant's /canvas/viewport, /approvals/pending,
# /org/templates etc. live on the tenant platform itself.
# Both legs share one origin (the tenant subdomain) so
# PLATFORM_URL="" forces canvas to fetch paths as relative,
# which land same-origin.
#
# Self-hosted / private-label deployments override this at
# build time with a specific backend (e.g. local dev:
# NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080).
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
# Re-login: the platform-image step's docker login wrote to
# the same config.json, so this is technically redundant — but
# making each push step self-contained keeps the workflow
# robust to step reordering / future extraction.
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+1
View File
@@ -0,0 +1 @@
staging trigger
+22 -14
View File
@@ -119,7 +119,6 @@
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
@@ -300,6 +299,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -348,6 +348,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -359,6 +360,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -370,6 +372,7 @@
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1126,6 +1129,7 @@
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.59.1"
},
@@ -2406,8 +2410,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -2530,6 +2533,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -2539,6 +2543,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2549,6 +2554,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2597,6 +2603,7 @@
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
@@ -2807,7 +2814,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -2818,7 +2824,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -3111,6 +3116,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3253,8 +3259,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.21.0",
@@ -3600,8 +3605,7 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
@@ -3609,6 +3613,7 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
@@ -3931,7 +3936,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5006,6 +5010,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5093,7 +5098,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5128,6 +5132,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5137,6 +5142,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5149,8 +5155,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -5598,7 +5603,8 @@
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.3",
@@ -5940,6 +5946,7 @@
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -6033,6 +6040,7 @@
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
-13
View File
@@ -274,17 +274,4 @@ body {
.react-flow__node {
animation: none !important;
}
/* React Flow Controls toolbar buttons — WCAG 2.4.7 focus-visible */
.react-flow__controls button:focus-visible {
outline: 2px solid var(--accent, #3b5bdb);
outline-offset: 2px;
}
/* React Flow Minimap nodes — WCAG 2.4.7 focus-visible */
.react-flow__minimap:focus-visible,
.react-flow__minimap svg:focus-visible {
outline: 2px solid var(--accent, #3b5bdb);
outline-offset: 2px;
}
}
+1 -17
View File
@@ -1,22 +1,6 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { cookies, headers } from "next/headers";
import "./globals.css";
// Self-hosted at build time → CSP-safe (font-src 'self' covers them
// because Next.js serves the .woff2 from /_next/static). Exposed as
// CSS variables so the mobile palette can reference them without
// importing this module.
const interFont = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const monoFont = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-jetbrains",
});
import { AuthGate } from "@/components/AuthGate";
import { CookieConsent } from "@/components/CookieConsent";
import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal";
@@ -95,7 +79,7 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{ __html: themeBootScript }}
/>
</head>
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
<body className="bg-surface text-ink">
<ThemeProvider initialTheme={theme}>
{/* AuthGate is a client component; it checks the session on mount
and bounces anonymous users to the control plane's login page
+1 -48
View File
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { Canvas } from "@/components/Canvas";
import { Legend } from "@/components/Legend";
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
import { MobileApp } from "@/components/mobile/MobileApp";
import { Spinner } from "@/components/Spinner";
import { connectSocket, disconnectSocket } from "@/store/socket";
import { useCanvasStore } from "@/store/canvas";
@@ -15,23 +14,6 @@ export default function Home() {
const hydrationError = useCanvasStore((s) => s.hydrationError);
const setHydrationError = useCanvasStore((s) => s.setHydrationError);
const [hydrating, setHydrating] = useState(true);
// < 640px viewport renders the dedicated mobile shell instead of the
// desktop canvas. Tri-state: `null` until matchMedia has resolved,
// then `true|false`. While null we keep the existing loading spinner
// up — that way mobile devices never flash the desktop tree (which
// they would if we defaulted to `false` and only flipped post-mount).
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
setIsMobile(false);
return;
}
const mq = window.matchMedia("(max-width: 639px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
// Distinct from hydrationError: platform-down is its own UX path
// (different copy, different action — the user's next step is to
// check local services, not to retry the API call). Tracked
@@ -69,10 +51,7 @@ export default function Home() {
};
}, []);
// Hold the spinner while data hydrates OR while the viewport
// resolution hasn't settled yet (avoids a desktop-tree flash on
// mobile devices between SSR-paint and matchMedia).
if (hydrating || isMobile === null) {
if (hydrating) {
return (
<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">
@@ -87,32 +66,6 @@ export default function Home() {
return <PlatformDownDiagnostic />;
}
if (isMobile) {
return (
<>
<MobileApp />
{hydrationError && (
<div
role="alert"
data-testid="hydration-error"
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999] px-6"
>
<p className="text-ink-mid text-sm text-center">{hydrationError}</p>
<button
onClick={() => {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
>
Retry
</button>
</div>
)}
</>
);
}
return (
<>
<Canvas />
+3 -3
View File
@@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
+1 -4
View File
@@ -43,9 +43,7 @@ export function BundleDropZone() {
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Guard against jsdom (no File API / dataTransfer.types) and other
// environments where dataTransfer may be null/undefined.
if (e.dataTransfer?.types?.includes("Files")) {
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
@@ -60,7 +58,6 @@ export function BundleDropZone() {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!e.dataTransfer?.files?.length) return;
const file = Array.from(e.dataTransfer.files).find(
(f) => f.name.endsWith(".bundle.json")
);
+1 -3
View File
@@ -308,9 +308,7 @@ function CanvasInner() {
showInteractive={false}
/>
<MiniMap
// hidden < sm: minimap eats ~30% of a phone screen and
// overlaps with the New Workspace FAB at bottom-right.
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 !hidden sm:!block"
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
// Mask dims off-viewport areas; tint matches the surface so
// the dimming doesn't show as a black bar in light mode.
maskColor={resolvedTheme === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(232, 226, 211, 0.7)"}
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-ink-mid hover:text-ink-mid text-xs"
>
<span aria-hidden="true"></span>
</button>
+1 -5
View File
@@ -106,11 +106,7 @@ export function ConfirmDialog({
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 cursor-pointer"
onClick={onCancel}
aria-label="Dismiss dialog"
/>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
<div
+1 -1
View File
@@ -165,7 +165,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Copy
</button>
@@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-ink-mid hover:text-ink-mid text-lg px-2"
>
</button>
@@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
>
Close
</button>
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
+2 -2
View File
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
>
Report
</a>
@@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
>
I&apos;ve saved it close
</button>
@@ -339,7 +339,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -376,7 +376,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -151,9 +151,8 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-pointer"
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-label="Close keyboard shortcuts dialog"
/>
{/* Dialog */}
+3 -6
View File
@@ -77,7 +77,7 @@ export function Legend() {
onClick={openLegend}
aria-label="Show legend"
title="Show legend"
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`}
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`}
>
<span aria-hidden="true" className="text-[10px]"></span>
Legend
@@ -86,10 +86,7 @@ export function Legend() {
}
return (
<div
data-testid="legend-panel"
className={`fixed bottom-6 ${leftClass} z-30 bg-surface-sunken/95 border border-line/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}
>
<div className={`fixed bottom-6 ${leftClass} z-30 bg-surface-sunken/95 border border-line/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
<div className="flex items-start justify-between mb-2">
<div className="text-[11px] font-semibold text-ink-mid uppercase tracking-wider">Legend</div>
<button
@@ -100,7 +97,7 @@ export function Legend() {
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-mid hover:text-ink hover:bg-surface-card/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 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-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
×
</button>
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
>
×
</button>
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
type="button"
onClick={loadEntries}
disabled={pluginUnavailable}
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Refresh memories"
>
Refresh
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Forget memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
>
Forget
</button>
+4 -4
View File
@@ -706,7 +706,7 @@ function AllKeysModal({
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -730,7 +730,7 @@ function AllKeysModal({
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[11px] text-accent hover:text-accent transition-colors"
>
Open Settings Panel
</button>
@@ -740,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
@@ -748,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
+1 -1
View File
@@ -210,7 +210,7 @@ export function OnboardingWizard() {
// Was hover:bg-surface-card on top of bg-surface-card —
// silent no-op hover. Lift to surface-elevated, matching
// the Cancel pattern in ConfirmDialog.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Next
</button>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
>
Import
</button>
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
+2 -2
View File
@@ -128,9 +128,9 @@ function PlanCard({
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
plan.highlighted
? "bg-accent-strong text-white hover:bg-accent disabled:bg-zinc-700 disabled:text-zinc-500"
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
}`}
>
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[9px] text-accent hover:text-accent mt-0.5"
>
back to model list
</button>
@@ -321,7 +321,7 @@ export function ProvisioningTimeout({
onClick={() => handleDismiss(entry.workspaceId)}
aria-label="Dismiss provisioning timeout warning"
title="Dismiss — keep this workspace running without the warning"
className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
>
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
>
View Logs
</button>
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>
Remove Workspace
</button>
@@ -34,8 +34,6 @@ function readPurchaseParams(): { open: boolean; item: string | null } {
function stripPurchaseParams() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
// Skip if there are no params to strip.
if (!url.searchParams.has("purchase_success") && !url.searchParams.has("item")) return;
url.searchParams.delete("purchase_success");
url.searchParams.delete("item");
// replaceState (not pushState) so back-button doesn't return to the
+1 -3
View File
@@ -144,10 +144,8 @@ export function SearchDialog() {
id={`search-result-${node.id}`}
role="option"
aria-selected={index === focusedIndex}
tabIndex={index === focusedIndex ? 0 : -1}
onClick={() => handleSelect(node.id)}
onFocus={() => { setFocusedIndex(index); inputRef.current?.focus(); }}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
index === focusedIndex ? "bg-surface-card/60" : "hover:bg-surface-card/40"
}`}
>
+23 -39
View File
@@ -63,21 +63,9 @@ export function SidePanel() {
? parsed
: SIDEPANEL_DEFAULT_WIDTH;
});
// On mobile (< 640px viewport) the configured width exceeds the screen,
// so the panel renders off-canvas-left. Force full-viewport width and
// disable resize on small screens; restore configured width on desktop.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(max-width: 639px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
useEffect(() => {
setSidePanelWidth(isMobile ? 0 : width);
}, [width, isMobile, setSidePanelWidth]);
setSidePanelWidth(width);
}, [width, setSidePanelWidth]);
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
const dragging = useRef(false);
const startX = useRef(0);
@@ -149,28 +137,24 @@ export function SidePanel() {
return (
<div
className={`fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200 ${
isMobile ? "left-0 w-screen" : "border-l"
}`}
style={isMobile ? undefined : { width }}
className="fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-l border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
style={{ width }}
>
{/* Resize handle — desktop only (no point resizing a full-screen mobile panel) */}
{!isMobile && (
<div
role="separator"
aria-label="Resize workspace panel"
aria-valuenow={width}
aria-valuemin={SIDEPANEL_MIN_WIDTH}
aria-valuemax={SIDEPANEL_MAX_WIDTH}
aria-orientation="vertical"
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
/>
)}
{/* Resize handle */}
<div
role="separator"
aria-label="Resize workspace panel"
aria-valuenow={width}
aria-valuemin={SIDEPANEL_MIN_WIDTH}
aria-valuemax={SIDEPANEL_MAX_WIDTH}
aria-orientation="vertical"
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
/>
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center justify-between px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<StatusDot status={node.data.status} size="md" />
@@ -197,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
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"
>
<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" />
@@ -206,7 +190,7 @@ export function SidePanel() {
</div>
{/* Capability summary */}
<div className="px-4 sm:px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
<div className="px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
<div className="flex flex-wrap gap-2">
<MetaPill label="Tier" value={`T${node.data.tier}`} />
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
@@ -268,7 +252,7 @@ export function SidePanel() {
onClick={() => {
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
}}
className="text-[11px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[11px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors"
>
Restart Now
</button>
@@ -311,8 +295,8 @@ export function SidePanel() {
</div>
{/* Footer — workspace ID */}
<div className="px-4 sm:px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all block truncate">
<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">
{selectedNodeId}
</span>
</div>
+6 -6
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
>
<span
aria-hidden="true"
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-ink-mid hover:text-ink-mid"
>
</button>
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -474,7 +474,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
@@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
>
Refresh templates
</button>
+1 -1
View File
@@ -138,7 +138,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
// Hover goes DARKER, not lighter — emerald-500 on white
// text drops contrast below AA vs emerald-700. Same trap
// I fixed in ApprovalBanner + ConfirmDialog.
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{submitting ? "Saving…" : "I agree"}
</button>
+3 -35
View File
@@ -1,7 +1,6 @@
"use client";
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
import { useCallback } from "react";
const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [
// Sun: explicit light
@@ -34,47 +33,17 @@ const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [
*
* Aligned with molecule-app/components/theme-toggle.tsx so the picker
* behaves identically across surfaces.
*
* WCAG 2.4.7: focus-visible rings on all three icon buttons.
* ARIA radiogroup pattern (2.1.1): Left/Right arrow keys move focus
* between options and update selection; Home/End jump to first/last.
*/
export function ThemeToggle({ className = "" }: { className?: string }) {
const { theme, setTheme } = useTheme();
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
next = (index + 1) % OPTIONS.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
next = (index - 1 + OPTIONS.length) % OPTIONS.length;
} else if (e.key === "Home") {
e.preventDefault();
next = 0;
} else if (e.key === "End") {
e.preventDefault();
next = OPTIONS.length - 1;
} else {
return;
}
setTheme(OPTIONS[next].value);
// Move focus to the new button so arrow-key navigation is continuous
const btns = (e.currentTarget.closest("[role=radiogroup]") as HTMLElement)?.querySelectorAll<HTMLButtonElement>("[role=radio]");
btns?.[next]?.focus();
},
[]
);
return (
<div
role="radiogroup"
aria-label="Theme preference"
className={`inline-flex items-center gap-0.5 rounded-md border border-line bg-surface-sunken p-0.5 ${className}`}
>
{OPTIONS.map((opt, index) => {
{OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
@@ -84,12 +53,11 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
aria-checked={active}
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-sunken " +
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink")
: "text-ink-mid hover:text-ink-mid")
}
>
<svg
+14 -20
View File
@@ -154,13 +154,13 @@ export function Toolbar() {
return (
<div
className="fixed top-3 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-3 sm:px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200 left-2 right-2 translate-x-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 overflow-x-auto sm:overflow-visible [&>*]:shrink-0"
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"
style={toolbarOffsetStyle}
>
{/* Logo / Title — title text drops on mobile to reclaim space */}
<div className="flex items-center gap-2 sm:pr-3 sm:border-r sm:border-line/60">
{/* Logo / Title */}
<div className="flex items-center gap-2 pr-3 border-r border-line/60">
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
<span className="hidden sm:inline text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
<span className="text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
</div>
{/* Status pills + workspace total in one segment — previously two
@@ -179,15 +179,15 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
<span className="hidden sm:inline text-ink-mid" aria-hidden="true">·</span>
<span className="hidden sm:inline text-[10px] text-ink-mid whitespace-nowrap">
<span className="text-ink-mid" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-mid whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
</span>
</div>
{/* WebSocket connection status */}
<div className="sm:pl-3 sm:border-l sm:border-line/60">
<div className="pl-3 border-l border-line/60">
<WsStatusPill status={wsStatus} />
</div>
@@ -280,7 +280,7 @@ export function Toolbar() {
}}
aria-label="Open audit trail for selected workspace"
title="Audit — view ledger for the selected workspace"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{/* Scroll / ledger icon */}
<svg
@@ -405,30 +405,24 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) {
if (status === "connected") {
return (
<div className="flex items-center gap-1.5" title="Real-time updates: connected">
{/* Decorative dot — not meaningful content for screen readers */}
<div className="flex items-center gap-1.5" title="Real-time updates: connected" aria-label="Real-time updates: connected">
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("online")}`} aria-hidden="true" />
{/* Status text exposed to screen readers (aria-hidden removed) */}
<span className="text-[10px] text-ink-mid">Live</span>
<span className="text-[10px] text-ink-mid" aria-hidden="true">Live</span>
</div>
);
}
if (status === "connecting") {
return (
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…">
{/* Decorative dot — not meaningful content for screen readers */}
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…" aria-label="Real-time updates: reconnecting">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" aria-hidden="true" />
{/* Status text exposed to screen readers (aria-hidden removed) */}
<span className="text-[10px] text-warm">Reconnecting</span>
<span className="text-[10px] text-warm" aria-hidden="true">Reconnecting</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected">
{/* Decorative dot — not meaningful content for screen readers */}
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected" aria-label="Real-time updates: disconnected">
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
{/* Status text exposed to screen readers (aria-hidden removed) */}
<span className="text-[10px] text-bad">Offline</span>
<span className="text-[10px] text-bad" aria-hidden="true">Offline</span>
</div>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function Tooltip({ text, children }: Props) {
onMouseLeave={leave}
onFocus={onFocus}
onBlur={onBlur}
aria-describedby={show ? tooltipId.current : undefined}
aria-describedby={tooltipId.current}
>
{children}
{show && text && createPortal(
@@ -39,190 +39,247 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(api, "get").mockResolvedValueOnce([]);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("renders nothing when there are no pending approvals", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
// Wait for the initial useEffect + api.get to resolve
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
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 vi.runOnlyPendingTimersAsync(); });
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", () => {
beforeEach(() => {
vi.useFakeTimers();
it("renders an alert card for each pending approval", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("renders an alert card for each pending approval", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
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 vi.runOnlyPendingTimersAsync(); });
const nameEls = screen.getAllByText(/test workspace needs approval/i);
expect(nameEls).toHaveLength(2);
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 vi.runOnlyPendingTimersAsync(); });
const reasons = screen.getAllByText(/requires human approval/i);
expect(reasons).toHaveLength(2);
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 () => {
vi.spyOn(api, "get").mockResolvedValueOnce([{
...pendingApproval("a1"),
reason: null,
}]);
const approval = pendingApproval("a1");
approval.reason = null;
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
expect(screen.queryByText(/requires human approval/i)).toBeNull();
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 vi.runOnlyPendingTimersAsync(); });
const approveBtns = screen.getAllByRole("button", { name: /Approve/i });
const denyBtns = screen.getAllByRole("button", { name: /Deny/i });
// 2 cards, each card has 1 Approve + 1 Deny button → 2 of each minimum
expect(approveBtns.length).toBeGreaterThanOrEqual(2);
expect(denyBtns.length).toBeGreaterThanOrEqual(2);
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 vi.runOnlyPendingTimersAsync(); });
const alert = screen.getAllByRole("alert")[0];
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alert = screen.getByRole("alert");
expect(alert.getAttribute("aria-live")).toBe("assertive");
});
});
describe("ApprovalBanner — decisions", () => {
describe("ApprovalBanner — polling", () => {
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValue({});
clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
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 vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
expect.objectContaining({ decision: "approved" })
);
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 vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /deny/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
expect.objectContaining({ decision: "denied" })
);
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 vi.runOnlyPendingTimersAsync(); });
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// One alert initially
expect(screen.getAllByRole("alert")).toHaveLength(1);
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(screen.queryByRole("alert")).toBeNull();
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 vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith("Approved", "success");
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 vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /deny/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith("Denied", "info");
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, "post").mockRejectedValue(new Error("Network error"));
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
"Failed to submit decision",
"error"
);
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, "post").mockRejectedValue(new Error("Network error"));
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(screen.getAllByRole("alert")).toHaveLength(1);
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", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(api, "get").mockResolvedValueOnce([]);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
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 vi.runOnlyPendingTimersAsync(); });
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
});
});
@@ -37,21 +37,12 @@ function makeBundle(name = "test-workspace"): File {
});
}
// jsdom doesn't define DragEvent globally; create a dragover event with
// dataTransfer.types stubbed to include "Files" so handleDragOver triggers.
function createDragOverEvent() {
return Object.assign(new Event("dragover", { bubbles: true, cancelable: true }), {
dataTransfer: { types: ["Files"], files: null },
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
const input = screen.getByLabelText("Import bundle file");
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
});
@@ -65,30 +56,30 @@ describe("BundleDropZone — render", () => {
});
describe("BundleDropZone — drag state", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
it("shows the drop overlay when a file is dragged over", async () => {
vi.useFakeTimers();
it("shows the drop overlay when a file is dragged over", () => {
render(<BundleDropZone />);
// Overlay should not be visible initially
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
expect(overlay?.className).toContain("fixed");
// Simulate drag-over: stub dataTransfer.types to include "Files"
// so handleDragOver calls setIsDragging(true)
const zone = document.body.querySelector('[class*="z-10"]') as HTMLElement;
// Simulate drag-over on the invisible drop zone
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
if (zone) {
const dragOverEvent = createDragOverEvent();
fireEvent.dragOver(zone, dragOverEvent);
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);
}
}
await act(async () => { vi.runOnlyPendingTimers(); });
// After dragOver, overlay should be visible. The overlay has z-20 class.
const overlay = screen.getByText("Drop Bundle to Import").closest('[class*="z-20"]');
expect(overlay).not.toBeNull();
vi.useRealTimers();
});
it("hides the drop overlay when not dragging", () => {
@@ -101,11 +92,7 @@ describe("BundleDropZone — drag state", () => {
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
it("triggers the hidden file input when the import button is clicked", () => {
render(<BundleDropZone />);
// Both the hidden file input and the button have aria-label="Import bundle file".
// Use the file input's id to select it uniquely.
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("file");
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();
@@ -120,7 +107,7 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
@@ -152,7 +139,7 @@ describe("BundleDropZone — import success", () => {
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -183,7 +170,7 @@ describe("BundleDropZone — import success", () => {
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -209,7 +196,7 @@ describe("BundleDropZone — import error", () => {
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -227,7 +214,7 @@ describe("BundleDropZone — import error", () => {
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -252,7 +239,7 @@ describe("BundleDropZone — import error", () => {
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -280,7 +267,7 @@ describe("BundleDropZone — importing state", () => {
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -312,7 +299,7 @@ describe("BundleDropZone — file input reset", () => {
});
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "../ContextMenu";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
import { api } from "@/lib/api";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
@@ -21,23 +20,16 @@ vi.mock("../Toaster", () => ({
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
// Mock api.post/patch via vi.spyOn — avoids vi.mock hoisting issues.
// Set up in beforeEach, cleaned up in afterEach.
let mockPost: ReturnType<typeof vi.fn>;
let mockPatch: ReturnType<typeof vi.fn>;
function setupApiMocks() {
mockPost = vi.fn().mockResolvedValue(undefined as void);
mockPatch = vi.fn().mockResolvedValue(undefined as void);
vi.spyOn(api, "post").mockImplementation(mockPost);
vi.spyOn(api, "patch").mockImplementation(mockPatch);
}
function resetApiMocks() {
mockPost?.mockReset();
mockPatch?.mockReset();
vi.restoreAllMocks();
}
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
patch: apiPatch,
get: vi.fn(),
},
}));
// ─── Mock store ──────────────────────────────────────────────────────────────
@@ -91,9 +83,6 @@ function openMenu(overrides?: Partial<NonNullable<typeof mockStoreState.contextM
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("ContextMenu — visibility", () => {
beforeEach(() => {
setupApiMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -107,7 +96,8 @@ describe("ContextMenu — visibility", () => {
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
@@ -143,7 +133,6 @@ describe("ContextMenu — visibility", () => {
});
describe("ContextMenu — close", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -157,7 +146,8 @@ describe("ContextMenu — close", () => {
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
@@ -175,19 +165,15 @@ describe("ContextMenu — close", () => {
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Tab is pressed while menu is focused", () => {
it("closes when Tab is pressed", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
// Tab only closes when the menu element itself has focus.
// When focus is on body, the document-level handler only handles Escape.
fireEvent.keyDown(menu, { key: "Tab" });
fireEvent.keyDown(document.body, { key: "Tab" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
});
describe("ContextMenu — menu items", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -201,7 +187,8 @@ describe("ContextMenu — menu items", () => {
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
@@ -215,19 +202,8 @@ describe("ContextMenu — menu items", () => {
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
// Chat and Terminal are rendered in the DOM even for offline nodes.
// For online nodes they are clickable; for offline nodes they are
// disabled (no hover effect). The context menu never omits them —
// it controls clickability via disabled flag. We verify the items
// are present and would be disabled by checking the aria-disabled
// attribute that the component sets.
const chatItem = screen.getByRole("menuitem", { name: /chat/i });
const terminalItem = screen.getByRole("menuitem", { name: /terminal/i });
expect(chatItem).toBeTruthy();
expect(terminalItem).toBeTruthy();
// For offline nodes, the button has aria-disabled="true"
expect(chatItem.getAttribute("aria-disabled")).toBe("true");
expect(terminalItem.getAttribute("aria-disabled")).toBe("true");
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
it("shows Pause for online nodes (not paused)", () => {
@@ -295,7 +271,6 @@ describe("ContextMenu — menu items", () => {
});
describe("ContextMenu — keyboard navigation", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -309,7 +284,8 @@ describe("ContextMenu — keyboard navigation", () => {
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
@@ -337,7 +313,6 @@ describe("ContextMenu — keyboard navigation", () => {
});
describe("ContextMenu — item actions", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -351,7 +326,8 @@ describe("ContextMenu — item actions", () => {
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
@@ -381,20 +357,20 @@ describe("ContextMenu — item actions", () => {
it("Pause calls the pause API and updates node status optimistically", async () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
mockPost.mockResolvedValue(undefined);
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
await act(async () => { /* flush */ });
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
});
it("Resume calls the resume API", async () => {
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
mockPost.mockResolvedValue(undefined);
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
await act(async () => { /* flush */ });
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
});
});
@@ -96,8 +96,9 @@ describe("extractMessageText — response result format", () => {
],
},
};
// Implementation joins all parts with newlines: "Direct text\nRoot text"
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
// Both are non-empty strings, so the first one wins (filter picks the first)
// The implementation: rText from rParts[0].text = "Direct text"
expect(extractMessageText(body)).toBe("Direct text");
});
});
@@ -7,20 +7,12 @@
* disabled state, aria-label.
*/
import React from "react";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KeyValueField } from "../ui/KeyValueField";
const AUTO_HIDE_MS = 30_000;
function getInput(): HTMLInputElement {
return document.body.querySelector("input") as HTMLInputElement;
}
function getRevealButton(): HTMLButtonElement {
return document.body.querySelector("button") as HTMLButtonElement;
}
describe("KeyValueField — render", () => {
afterEach(() => {
cleanup();
@@ -30,11 +22,12 @@ describe("KeyValueField — render", () => {
it("renders a password input by default", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(getInput().getAttribute("type")).toBe("password");
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
const input = container.querySelector("input");
expect(input).toBeTruthy();
expect(input!.getAttribute("type")).toBe("password");
@@ -42,32 +35,32 @@ describe("KeyValueField — render", () => {
it("uses the provided aria-label", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
expect(getInput().getAttribute("aria-label")).toBe("My secret field");
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(getInput().getAttribute("aria-label")).toBe("Secret value");
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(getInput().getAttribute("disabled")).toBe("");
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(getInput().getAttribute("placeholder")).toBe("Enter API key");
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(getInput().getAttribute("spellcheck")).toBe("false");
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(getInput().getAttribute("autocomplete")).toBe("off");
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
});
});
@@ -81,25 +74,28 @@ describe("KeyValueField — onChange", () => {
it("calls onChange when input changes", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(getInput(), { target: { value: "abc" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
// jsdom's fireEvent.change doesn't update input.value, so simulate by
// directly setting the property before firing the event.
const input = getInput();
Object.defineProperty(input, "value", { value: "abc ", writable: true });
fireEvent.change(input);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("passes value through unchanged when no whitespace trimming needed", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(getInput(), { target: { value: "no-change" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
expect(onChange).toHaveBeenCalledWith("no-change");
});
});
@@ -124,9 +120,10 @@ describe("KeyValueField — auto-hide timer", () => {
render(<KeyValueField value="secret" onChange={onChange} />);
// Reveal the value
fireEvent.click(getRevealButton());
const input = document.body.querySelector("input");
fireEvent.click(document.body.querySelector("button")!);
// After reveal, input type should be text (not password)
expect(getInput().getAttribute("type")).not.toBe("password");
expect(input?.getAttribute("type")).not.toBe("password");
// Advance 30 seconds
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
@@ -138,33 +135,36 @@ describe("KeyValueField — auto-hide timer", () => {
// Since we can't read internal state, we verify the behavior by checking
// the input type (it flips back to password after auto-hide).
// The timer callback calls setRevealed(false) which flips type back to password.
expect(getInput().getAttribute("type")).toBe("password");
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
expect(typeAfter).toBe("password");
});
it("does not fire auto-hide before 30 seconds", async () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(getRevealButton());
fireEvent.click(document.body.querySelector("button")!);
// Advance 29 seconds — should NOT have hidden yet
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS - 1000); });
expect(getInput().getAttribute("type")).toBe("text");
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
// Still revealed (type=text) after 29s
expect(typeAfter).toBe("text");
});
it("clears the timer when revealed flips back to false before timeout", () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(getRevealButton());
fireEvent.click(document.body.querySelector("button")!);
// Hide manually before the 30s auto-hide
fireEvent.click(getRevealButton());
fireEvent.click(document.body.querySelector("button")!);
// Advance full 30s — should not crash (timer already cleared)
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Still hidden (we hid it manually)
expect(getInput().getAttribute("type")).toBe("password");
expect(document.body.querySelector("input")?.getAttribute("type")).toBe("password");
});
});
@@ -149,10 +149,7 @@ describe("Legend — palette offset positioning", () => {
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// The outer panel div is the one with position classes (fixed bottom-6).
// screen.getByText("Legend") returns the inner heading text; get its
// closest ancestor with position-related classes (bottom-6).
const panel = screen.getByText("Legend").closest("div[class*='bottom-6']");
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-4");
});
@@ -161,7 +158,7 @@ describe("Legend — palette offset positioning", () => {
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div[class*='bottom-6']");
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-[296px]");
});
});
@@ -140,7 +140,7 @@ describe("OnboardingWizard — auto-advance", () => {
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
render(<OnboardingWizard />);
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
// Simulate a node being added to the store and re-render
@@ -148,12 +148,10 @@ describe("OnboardingWizard — auto-advance", () => {
render(<OnboardingWizard />);
await waitFor(() => {
// OnboardingWizard's auto-advance effect has step as a dependency,
// meaning it only runs on mount. When nodes appear AFTER mount,
// the component stays on welcome step. Verify the component still
// renders (i.e., is not broken by the nodes change).
expect(screen.queryByText("Welcome to Molecule AI")).toBeTruthy();
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
@@ -18,9 +18,7 @@ import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/re
// endpoint is idempotent so no data hazard, but the extra
// PUT is wasteful and harder to reason about.
const { createSecretMock } = vi.hoisted(() => ({
createSecretMock: vi.fn().mockResolvedValue(undefined),
}));
const createSecretMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/api/secrets", () => ({
createSecret: (...args: unknown[]) => createSecretMock(...args),
@@ -6,213 +6,237 @@
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*
* jsdom requires overriding window.location directly (Object.defineProperty
* with writable:true) since vi.stubGlobal("location") does not propagate to
* window.location.search in the jsdom environment.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── URL stub helper ───────────────────────────────────────────────────────────
// jsdom's window.location.search is read-only by default. We use
// Object.defineProperty to make it writable so tests can control the URL.
function setSearch(search: string) {
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, search },
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function clearSearch() {
setSearch("");
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
// Helper: wait for dialog to appear (real timers)
async function waitForDialog() {
await act(async () => { await new Promise((r) => setTimeout(r, 50)); });
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
replaceUrl("http://localhost/");
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
clearSearch();
vi.useRealTimers();
});
it("renders nothing when URL has no purchase_success param", () => {
setSearch("");
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
setSearch("?foo=bar");
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
setSearch("?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await waitForDialog();
// useEffect fires after mount
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders the dialog when ?purchase_success=true is present", async () => {
setSearch("?purchase_success=true");
replaceUrl("http://localhost/?purchase_success=true");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", async () => {
setSearch("?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
it("shows the item name when &item= is present", async () => {
setSearch("?purchase_success=1&item=MyAgent");
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("MyAgent")).toBeTruthy();
expect(screen.getByText("Purchase successful")).toBeTruthy();
});
it("shows 'Your new agent' when no item param is present", async () => {
setSearch("?purchase_success=1");
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Your new agent")).toBeTruthy();
});
it("decodes URI-encoded item names", async () => {
setSearch("?purchase_success=1&item=Claude%20Code%20Agent");
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers(); // ensure no fake timer leak
clearSearch();
vi.useRealTimers();
});
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitForDialog();
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await waitForDialog();
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
await waitForDialog();
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
// Auto-dismiss tests use real timers — the component's setTimeout fires
// naturally after 5s in the test environment. vi.useFakeTimers() is not used
// here because React 18 + fake timers require careful microtask/macrotask
// interleaving that is fragile in jsdom; real timers are reliable.
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// The component's AUTO_DISMISS_MS = 5000ms. In jsdom, setTimeout fires
// reliably. Wait long enough for 2 dismiss cycles to ensure the first fires.
await act(async () => { await new Promise((r) => setTimeout(r, 11000)); });
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeNull();
}, 15000); // extended timeout for real-timer wait
});
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
expect(screen.getByRole("dialog")).toBeTruthy();
// Wait 4s — just under the 5s auto-dismiss threshold
await act(async () => { await new Promise((r) => setTimeout(r, 4000)); });
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
clearSearch();
vi.useRealTimers();
});
it("strips purchase_success and item params from the URL on mount", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const url = new URL(window.location.href);
expect(url.searchParams.get("purchase_success")).toBeNull();
expect(url.searchParams.get("item")).toBeNull();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
vi.useRealTimers(); // ensure clean state
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers(); // ensure no fake timer leak
clearSearch();
vi.useRealTimers();
});
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await waitForDialog();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
@@ -220,12 +244,12 @@ describe("PurchaseSuccessModal — accessibility", () => {
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
});
// Focus test: verify close button exists after dialog renders.
// We test presence (not focus) since rAF focus is tricky in jsdom.
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
// Use getByRole which is more reliable than querySelector
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
await act(async () => {
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
});
@@ -6,47 +6,42 @@
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, fireEvent, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
it("renders a button element", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(container.querySelector("button")).toBeTruthy();
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
});
it("uses the provided aria-label", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("aria-label")).toBe("Show password");
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
});
it("uses default aria-label when label prop is omitted", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("aria-label")).toBe("Toggle reveal secret");
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle visibility");
});
it("has title 'Show value' when revealed=false", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("title")).toBe("Show value");
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
});
it("has title 'Hide value' when revealed=true", () => {
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("title")).toBe("Hide value");
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
});
});
describe("RevealToggle — interaction", () => {
it("calls onToggle when clicked", () => {
const onToggle = vi.fn();
const { container } = render(<RevealToggle revealed={false} onToggle={onToggle} />);
const btn = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(btn);
render(<RevealToggle revealed={false} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
@@ -54,6 +49,7 @@ describe("RevealToggle — interaction", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye icon has a circle path for the eye
expect(container.innerHTML).toContain("M1 12s4-8 11-8");
});
@@ -61,6 +57,7 @@ describe("RevealToggle — interaction", () => {
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye-off has a diagonal line
expect(container.innerHTML).toContain("x1");
expect(container.innerHTML).toContain("y2");
});
@@ -102,7 +102,6 @@ describe("SearchDialog — keyboard shortcuts", () => {
});
it("clears the query when Cmd+K opens the dialog", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
@@ -274,9 +273,9 @@ describe("SearchDialog — listbox navigation", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob (index 1)
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2"); // Bob
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
@@ -29,9 +29,7 @@ vi.mock("../Tooltip", () => ({
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Mock canvas store ────────────────────────────────────────────────────────
// Use vi.hoisted() so mock refs are available in the vi.mock factory
// and in test bodies without triggering vitest's top-level variable rule.
const { mockSetPanelTab } = vi.hoisted(() => ({ mockSetPanelTab: vi.fn() }));
const mockSetPanelTab = vi.fn();
const mockStoreState = {
selectedNodeId: "ws-1",
@@ -5,7 +5,7 @@
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*/
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
@@ -14,30 +14,29 @@ describe("Spinner — size variants", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// SVG elements use SVGAnimatedString for className — use classList instead
expect(svg!.classList.contains("w-3")).toBe(true);
expect(svg!.classList.contains("h-3")).toBe(true);
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.classList.contains("w-4")).toBe(true);
expect(svg?.classList.contains("h-4")).toBe(true);
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.classList.contains("w-5")).toBe(true);
expect(svg?.classList.contains("h-5")).toBe(true);
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.classList.contains("w-4")).toBe(true);
expect(svg?.classList.contains("h-4")).toBe(true);
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
@@ -49,11 +48,11 @@ describe("Spinner — size variants", () => {
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.classList.contains("motion-safe:animate-spin")).toBe(true);
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
const { container } = render(<Spinner />);
expect(container.querySelectorAll("svg").length).toBe(1);
});
});
});
@@ -6,52 +6,52 @@
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
const { container } = render(<StatusBadge status="verified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✓");
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
});
it("renders invalid status with ✗ icon", () => {
const { container } = render(<StatusBadge status="invalid" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✗");
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
});
it("renders unverified status with ○ icon", () => {
const { container } = render(<StatusBadge status="unverified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("○");
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
});
it("has role=status on the badge element", () => {
const { container } = render(<StatusBadge status="verified" />);
expect(container.querySelector('[role="status"]')).toBeTruthy();
render(<StatusBadge status="verified" />);
expect(screen.getByRole("status")).toBeTruthy();
});
it("includes the config className on the rendered element", () => {
const { container } = render(<StatusBadge status="verified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.classList.contains("status-badge--valid")).toBe(true);
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--valid");
});
it("includes status-badge--invalid class for invalid status", () => {
const { container } = render(<StatusBadge status="invalid" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.classList.contains("status-badge--invalid")).toBe(true);
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--invalid");
});
it("includes status-badge--unverified class for unverified status", () => {
const { container } = render(<StatusBadge status="unverified" />);
const badge = container.querySelector('[role="status"]') as HTMLElement;
expect(badge.classList.contains("status-badge--unverified")).toBe(true);
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--unverified");
});
});
@@ -12,89 +12,89 @@
* - glow class applied when STATUS_CONFIG declares one
*/
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
const { container } = render(<StatusDot status="online" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-emerald-400")).toBe(true);
expect(dot.classList.contains("shadow-emerald-400/50")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="offline" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-zinc-500")).toBe(true);
expect(dot.classList.contains("shadow-")).toBe(false);
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", () => {
const { container } = render(<StatusDot status="degraded" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-amber-400")).toBe(true);
expect(dot.classList.contains("shadow-amber-400/50")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="failed" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-red-400")).toBe(true);
expect(dot.classList.contains("shadow-red-400/50")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="paused" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-indigo-400")).toBe(true);
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
const { container } = render(<StatusDot status="not_configured" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-amber-300")).toBe(true);
expect(dot.classList.contains("shadow-amber-300/50")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="provisioning" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-sky-400")).toBe(true);
expect(dot.classList.contains("motion-safe:animate-pulse")).toBe(true);
expect(dot.classList.contains("shadow-sky-400/50")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="alien_artifact" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("bg-zinc-500")).toBe(true);
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)", () => {
const { container } = render(<StatusDot status="online" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("w-2")).toBe(true);
expect(dot.classList.contains("h-2")).toBe(true);
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)", () => {
const { container } = render(<StatusDot status="online" size="md" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.classList.contains("w-2.5")).toBe(true);
expect(dot.classList.contains("h-2.5")).toBe(true);
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", () => {
const { container } = render(<StatusDot status="online" />);
const dot = container.querySelector('[role="img"]') as HTMLElement;
expect(dot.getAttribute("aria-hidden")).toBe("true");
render(<StatusDot status="online" />);
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
});
});
@@ -11,13 +11,12 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestConnectionButton } from "../ui/TestConnectionButton";
import type { SecretGroup } from "@/types/secrets";
import { validateSecret } from "@/lib/api/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
// vi.mock is hoisted, so validateSecret (imported above) refers to the mocked
// namespace value once vi.mock runs. Use vi.mocked() to access it in tests.
const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: vi.fn(),
validateSecret: mockValidateSecret,
}));
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
@@ -30,7 +29,7 @@ describe("TestConnectionButton — render", () => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.mocked(validateSecret).mockReset();
mockValidateSecret.mockReset();
});
it("renders 'Test connection' button in idle state", () => {
@@ -40,12 +39,12 @@ describe("TestConnectionButton — render", () => {
it("disables button when secretValue is empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
});
it("enables button when secretValue is non-empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
expect(screen.getByRole("button").hasAttribute("disabled")).toBe(false);
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
});
});
@@ -58,21 +57,21 @@ describe("TestConnectionButton — state machine", () => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.mocked(validateSecret).mockReset();
mockValidateSecret.mockReset();
});
it("shows 'Testing…' while validateSecret is pending", async () => {
vi.mocked(validateSecret).mockImplementation(() => new Promise(() => {})); // never resolves
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// Button should show testing label and be disabled
expect(screen.getByRole("button", { name: "Testing…" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
});
it("shows 'Connected ✓' on success", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
@@ -82,7 +81,7 @@ describe("TestConnectionButton — state machine", () => {
});
it("shows 'Test failed' on validation failure", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Invalid key format" });
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
fireEvent.click(screen.getByRole("button"));
@@ -92,7 +91,7 @@ describe("TestConnectionButton — state machine", () => {
});
it("shows error detail when validation returns invalid with message", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Permission denied" });
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
fireEvent.click(screen.getByRole("button"));
@@ -103,15 +102,14 @@ describe("TestConnectionButton — state machine", () => {
});
it("shows generic error message on unexpected exception", async () => {
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
// The error detail is hardcoded to "Connection timed out. Service may be down."
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i);
expect(screen.getByText(/timeout/i)).toBeTruthy();
});
});
@@ -124,11 +122,11 @@ describe("TestConnectionButton — auto-reset", () => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.mocked(validateSecret).mockReset();
mockValidateSecret.mockReset();
});
it("resets to idle after 3 seconds on success", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
@@ -142,7 +140,7 @@ describe("TestConnectionButton — auto-reset", () => {
});
it("resets to idle after 5 seconds on failure", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Bad key" });
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
fireEvent.click(screen.getByRole("button"));
@@ -156,7 +154,7 @@ describe("TestConnectionButton — auto-reset", () => {
});
it("does not reset before 3 seconds on success", async () => {
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
@@ -180,12 +178,12 @@ describe("TestConnectionButton — onResult callback", () => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.mocked(validateSecret).mockReset();
mockValidateSecret.mockReset();
});
it("calls onResult(true) on success", async () => {
const onResult = vi.fn();
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
@@ -196,7 +194,7 @@ describe("TestConnectionButton — onResult callback", () => {
it("calls onResult(false) on failure", async () => {
const onResult = vi.fn();
vi.mocked(validateSecret).mockResolvedValue({ valid: false });
mockValidateSecret.mockResolvedValue({ valid: false });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
@@ -207,7 +205,7 @@ describe("TestConnectionButton — onResult callback", () => {
it("calls onResult(false) when exception is thrown", async () => {
const onResult = vi.fn();
vi.mocked(validateSecret).mockRejectedValue(new Error("network error"));
mockValidateSecret.mockRejectedValue(new Error("network error"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
@@ -3,11 +3,10 @@
* Tests for ThemeToggle component.
*
* Covers: renders all three options, aria radiogroup semantics,
* aria-checked per option, setTheme calls on click, keyboard navigation
* (arrow keys, Home/End), focus-visible rings, custom className prop.
* aria-checked per option, setTheme calls on click, custom className prop.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/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";
@@ -132,86 +131,6 @@ describe("ThemeToggle — interaction", () => {
});
});
describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("moves to the next option on ArrowRight and wraps around", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("moves to the previous option on ArrowLeft", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("moves to the next option on ArrowDown", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
it("jumps to the first option on Home", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("jumps to the last option on End", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
describe("ThemeToggle — className prop", () => {
it("passes custom className to the radiogroup", () => {
render(<ThemeToggle className="my-custom-class" />);
@@ -12,19 +12,7 @@ import { Tooltip } from "../Tooltip";
afterEach(cleanup);
// Tooltip uses useRef ids that increment per render.
// After cleanup, reset so IDs are predictable again.
// Since tooltipIdCounter is a module-level var, we just re-render in each test.
describe("Tooltip — render", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
@@ -145,15 +133,8 @@ describe("Tooltip — hover delay", () => {
});
describe("Tooltip — keyboard focus reveal", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows tooltip on focus without needing the hover timer", () => {
vi.useFakeTimers();
render(
<Tooltip text="Keyboard tip">
<button type="button">Focus me</button>
@@ -165,9 +146,11 @@ describe("Tooltip — keyboard focus reveal", () => {
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>
@@ -183,19 +166,13 @@ describe("Tooltip — keyboard focus reveal", () => {
btn.blur();
});
expect(screen.queryByRole("tooltip")).toBeNull();
vi.useRealTimers();
});
});
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("dismisses tooltip on Escape without blurring the trigger", () => {
vi.useFakeTimers();
render(
<Tooltip text="Esc dismiss tip">
<button type="button">Hover me</button>
@@ -207,19 +184,19 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
// Focus the trigger so activeElement is the button (jsdom mouseEnter doesn't focus)
act(() => { btn.focus(); });
const activeBefore = document.activeElement;
expect(document.activeElement).toBe(btn);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Trigger element was the active element before Esc (button)
expect(activeBefore?.tagName).toBe("BUTTON");
// 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>
@@ -237,51 +214,22 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
});
// Tooltip still visible
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
});
describe("Tooltip — aria-describedby", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("associates tooltip with the trigger wrapper via 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");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// The aria-describedby is on the wrapper div (the Tooltip root element),
// not on the children button directly.
const wrapper = document.body.querySelector('[aria-describedby]') as HTMLElement;
expect(wrapper).toBeTruthy();
const describedBy = wrapper.getAttribute("aria-describedby");
const describedBy = btn.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id in the portal
expect(document.getElementById(describedBy!)).toBeTruthy();
});
// WCAG 1.4.13 (Content on Hover or Focus): aria-describedby must NOT be set
// when the tooltip is hidden. An unconditional aria-describedby causes screen
// readers to announce tooltip text even when the tooltip is not visible, which
// is an accessibility regression. The fix makes it conditional on `show`.
it("does NOT set aria-describedby when tooltip is hidden (WCAG 1.4.13)", () => {
render(
<Tooltip text="Hidden tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Without any hover/focus, the tooltip is not shown
const wrapper = document.body.querySelector('[aria-describedby]');
expect(wrapper).toBeNull();
// The describedby id matches the tooltip id
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
expect(document.getElementById(tooltipId)).toBeTruthy();
});
});
+12 -18
View File
@@ -18,39 +18,33 @@ vi.mock("../settings/SettingsButton", () => ({
describe("TopBar — render", () => {
it("renders a header element", () => {
const { container } = render(<TopBar />);
expect(container.querySelector("header")).toBeTruthy();
render(<TopBar />);
expect(document.body.querySelector("header")).toBeTruthy();
});
it("renders the canvas name (default)", () => {
const { container } = render(<TopBar />);
expect(container.textContent).toContain("Canvas");
render(<TopBar />);
expect(screen.getByText("Canvas")).toBeTruthy();
});
it("renders a custom canvas name", () => {
const { container } = render(<TopBar canvasName="My Org Canvas" />);
expect(container.textContent).toContain("My Org Canvas");
render(<TopBar canvasName="My Org Canvas" />);
expect(screen.getByText("My Org Canvas")).toBeTruthy();
});
it("renders the '+ New Agent' button", () => {
const { container } = render(<TopBar />);
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => /new agent/i.test(b.textContent ?? "")
);
expect(btn).toBeTruthy();
render(<TopBar />);
expect(screen.getByRole("button", { name: /new agent/i })).toBeTruthy();
});
it("renders the SettingsButton", () => {
const { container } = render(<TopBar />);
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.getAttribute("aria-label") === "Settings"
);
expect(btn).toBeTruthy();
render(<TopBar />);
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("has the logo span with aria-hidden", () => {
const { container } = render(<TopBar />);
const logo = container.querySelector('[aria-hidden="true"]');
render(<TopBar />);
const logo = document.body.querySelector('[aria-hidden="true"]');
expect(logo?.textContent).toBe("☁");
});
});
@@ -19,23 +19,19 @@ describe("ValidationHint — error state", () => {
it("includes the warning icon in error state", () => {
render(<ValidationHint error="Too short" />);
// The warning icon is a separate span with aria-hidden
const container = document.body.querySelector('[role="alert"]');
expect(container?.innerHTML).toContain("⚠");
expect(screen.getByText(/⚠/)).toBeTruthy();
});
it("uses the error class on the paragraph element", () => {
render(<ValidationHint error="Bad input" />);
const el = document.body.querySelector(".validation-hint--error");
expect(el).toBeTruthy();
const el = screen.getByRole("alert");
expect(el.className).toContain("validation-hint--error");
});
it("renders error even when showValid is true", () => {
const { container } = render(<ValidationHint error="Oops" showValid={true} />);
const alertEl = container.querySelector('[role="alert"]');
expect(alertEl).toBeTruthy();
// No ✓ checkmark in error state
expect(container.querySelector('[role="status"]')).toBeNull();
render(<ValidationHint error="Oops" showValid={true} />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText(/✓/)).toBeNull();
});
});
@@ -47,9 +43,7 @@ describe("ValidationHint — valid state", () => {
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
// The valid hint contains a span with ✓ followed by "Valid format"
const container = document.body.querySelector(".validation-hint--valid");
expect(container?.innerHTML).toContain("✓");
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
@@ -63,21 +63,13 @@ describe("createMessage", () => {
it("returns a frozen object (prevents accidental mutation)", () => {
const msg = createMessage("user", "hello");
// The factory returns a plain object; the freeze call is a no-op in the
// test environment since Object.freeze is overridden. Verify the object
// has the expected shape instead.
expect(msg.id).toBeTruthy();
expect(msg.role).toBe("user");
expect(msg.content).toBe("hello");
expect(Object.isFrozen(msg)).toBe(true);
});
it("returns a plain object with expected keys", () => {
const msg = createMessage("user", "hello");
const keys = Object.keys(msg);
// Must have id, role, content, timestamp; may also have attachments
expect(keys).toContain("id");
expect(keys).toContain("role");
expect(keys).toContain("content");
expect(keys).toContain("timestamp");
expect(Object.keys(msg).sort()).toEqual(
["id", "role", "content", "timestamp"].sort()
);
});
});
+1 -1
View File
@@ -119,7 +119,7 @@ function A2AEdgeImpl({
onClick={handleClick}
aria-label={ariaLabel}
title="Open source workspace's activity feed"
className={`px-2 py-0.5 rounded-full bg-surface-sunken/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-surface-card hover:border-opacity-100 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1`}
className={`px-2 py-0.5 rounded-full bg-surface-sunken/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-surface-card hover:border-opacity-100 transition-colors cursor-pointer`}
>
{labelText}
</button>
@@ -122,7 +122,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
type="button"
onClick={handleCancel}
disabled={submitting}
className="mol-deploy-cancel px-2 py-0.5 rounded text-[10px] font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="mol-deploy-cancel px-2 py-0.5 rounded text-[10px] font-semibold"
>
{submitting ? "Deleting…" : "Yes"}
</button>
@@ -130,7 +130,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
type="button"
onClick={() => setConfirming(false)}
disabled={submitting}
className="px-2 py-0.5 rounded bg-surface-card/80 hover:bg-surface-card text-[10px] text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-0.5 rounded bg-surface-card/80 hover:bg-surface-card text-[10px] text-ink"
>
No
</button>
@@ -148,7 +148,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
e.stopPropagation();
setConfirming(true);
}}
className="nodrag mol-deploy-cancel mol-deploy-cancel-pulse absolute -top-7 right-1 z-20 flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="nodrag mol-deploy-cancel mol-deploy-cancel-pulse absolute -top-7 right-1 z-20 flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-md"
aria-label={`Cancel deployment of ${rootName}`}
>
<svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
-210
View File
@@ -1,210 +0,0 @@
"use client";
// MobileApp — top-level mobile shell.
// Local route state, bottom tab bar, theme-aware palette. Only rendered
// on viewports < 640px (see app/page.tsx). The desktop Canvas is not
// instantiated when MobileApp is active, so no React Flow + heavy
// chrome cost on phones.
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "@/lib/theme-provider";
import { TabBar, type MobileTabId } from "./components";
import { MobileCanvas } from "./MobileCanvas";
import { MobileChat } from "./MobileChat";
import { MobileComms } from "./MobileComms";
import { MobileDetail } from "./MobileDetail";
import { MobileHome } from "./MobileHome";
import { MobileMe } from "./MobileMe";
import { MobileSpawn } from "./MobileSpawn";
import { usePalette } from "./palette";
import { MobileAccentProvider } from "./palette-context";
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
const ROUTES: Route[] = ["home", "canvas", "detail", "chat", "comms", "me"];
const ACCENT_KEY = "molecule.mobile.accent";
const DENSITY_KEY = "molecule.mobile.density";
function readStored<T extends string>(key: string, fallback: T, allowed?: T[]): T {
if (typeof window === "undefined") return fallback;
try {
const v = window.localStorage.getItem(key);
if (!v) return fallback;
if (allowed && !allowed.includes(v as T)) return fallback;
return v as T;
} catch {
return fallback;
}
}
interface UrlState {
route: Route;
agentId: string | null;
}
/** Parse the current URL into a (route, agentId) pair. Reads from
* `?m=<route>&a=<agentId>` — `home` is the default when `m` is
* absent. Detail/chat without an agent id collapse back to `home`
* because they're meaningless without one. */
function readRouteFromUrl(): UrlState {
if (typeof window === "undefined") return { route: "home", agentId: null };
const params = new URLSearchParams(window.location.search);
const m = params.get("m");
const a = params.get("a");
const route: Route = ROUTES.includes(m as Route) ? (m as Route) : "home";
if ((route === "detail" || route === "chat") && !a) {
return { route: "home", agentId: null };
}
return { route, agentId: a };
}
/** Build the canonical URL for a (route, agentId) pair, preserving any
* unrelated search params and the existing hash. `home` is the default
* state, so we drop `m` from the URL to keep the no-state link clean. */
function buildRouteUrl(route: Route, agentId: string | null): string {
if (typeof window === "undefined") return "";
const params = new URLSearchParams(window.location.search);
if (route === "home") params.delete("m");
else params.set("m", route);
if (agentId && (route === "detail" || route === "chat")) params.set("a", agentId);
else params.delete("a");
const search = params.toString();
return window.location.pathname + (search ? "?" + search : "") + window.location.hash;
}
export function MobileApp() {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === "dark";
const p = usePalette(dark);
// Seed route + agentId from the URL so deep links like
// `/?m=detail&a=ws-42` open straight on the right screen.
const [route, setRoute] = useState<Route>(() => readRouteFromUrl().route);
const [agentId, setAgentId] = useState<string | null>(() => readRouteFromUrl().agentId);
const [showSpawn, setShowSpawn] = useState(false);
// Sync route state → URL via history.pushState. Skip the push when
// the URL is already what we'd produce — that handles the initial
// mount (we read FROM the URL) and prevents duplicate history entries
// when popstate restores state we just pushed.
useEffect(() => {
if (typeof window === "undefined") return;
const current = readRouteFromUrl();
if (current.route === route && current.agentId === agentId) return;
const url = buildRouteUrl(route, agentId);
window.history.pushState({ route, agentId }, "", url);
}, [route, agentId]);
// Sync URL → route state on browser back/forward. The popstate event
// fires AFTER the URL has changed, so re-reading is correct.
useEffect(() => {
if (typeof window === "undefined") return;
const onPop = () => {
const next = readRouteFromUrl();
setRoute(next.route);
setAgentId(next.agentId);
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const [accent, setAccentState] = useState<string>(() => readStored(ACCENT_KEY, "#2f9e6a"));
const [density, setDensityState] = useState<"compact" | "regular">(() =>
readStored<"compact" | "regular">(DENSITY_KEY, "regular", ["compact", "regular"]),
);
// Persist accent. The accent itself is propagated into every palette
// read via React context (MobileAccentProvider below) — never by
// mutating the MOL_LIGHT/MOL_DARK singletons.
useEffect(() => {
try {
window.localStorage.setItem(ACCENT_KEY, accent);
} catch {
/* noop */
}
}, [accent]);
useEffect(() => {
try {
window.localStorage.setItem(DENSITY_KEY, density);
} catch {
/* noop */
}
}, [density]);
const activeTab: MobileTabId = useMemo(() => {
if (route === "canvas") return "canvas";
if (route === "comms") return "comms";
if (route === "me") return "me";
return "agents";
}, [route]);
const onTabChange = (id: MobileTabId) => {
if (id === "agents") setRoute("home");
else if (id === "canvas") setRoute("canvas");
else if (id === "comms") setRoute("comms");
else if (id === "me") setRoute("me");
};
const openAgent = (id: string) => {
setAgentId(id);
setRoute("detail");
};
// Tab bar visible everywhere except chat (per design).
const showTabBar = route !== "chat";
return (
<MobileAccentProvider accent={accent}>
<main
style={{
position: "fixed",
inset: 0,
background: p.bg,
color: p.text,
overflow: "hidden",
contain: "strict",
}}
>
{route === "home" && (
<MobileHome
dark={dark}
density={density}
onOpen={openAgent}
onSpawn={() => setShowSpawn(true)}
/>
)}
{route === "canvas" && (
<MobileCanvas dark={dark} onOpen={openAgent} onSpawn={() => setShowSpawn(true)} />
)}
{route === "detail" && agentId && (
<MobileDetail
agentId={agentId}
dark={dark}
onBack={() => setRoute("home")}
onChat={() => setRoute("chat")}
/>
)}
{route === "chat" && agentId && (
<MobileChat agentId={agentId} dark={dark} onBack={() => setRoute("detail")} />
)}
{route === "comms" && <MobileComms dark={dark} />}
{route === "me" && (
<MobileMe
dark={dark}
accent={accent}
setAccent={setAccentState}
density={density}
setDensity={setDensityState}
/>
)}
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
</main>
</MobileAccentProvider>
);
}
@@ -1,401 +0,0 @@
"use client";
// 02 · Canvas graph — pan-friendly mini-graph with status-coloured nodes.
// Node positions come from the live store (the same x/y the desktop canvas
// uses). The screen normalizes them to a 0..1 viewport so the graph fits
// the phone frame regardless of where the user has the desktop pan/zoom.
import { useMemo, useRef, useState, type TouchEvent as ReactTouchEvent } from "react";
import { useCanvasStore } from "@/store/canvas";
import { type MobileAgent, WorkspacePill, toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
const SCALE_MIN = 0.5;
const SCALE_MAX = 3;
interface Gesture {
kind: "none" | "pinch" | "pan";
startDist?: number;
startScale?: number;
startTouch?: { x: number; y: number };
startPan?: { x: number; y: number };
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export function MobileCanvas({
dark,
onOpen,
onSpawn,
}: {
dark: boolean;
onOpen: (agentId: string) => void;
onSpawn: () => void;
}) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
// Project store nodes into 0..100 (%) space, leaving 8% padding on each
// edge so cards don't clip. Falls back to a uniform circular layout
// when every node sits at (0,0) — common right after first hydrate.
const layout = useMemo(() => {
const items = nodes.map((n) => ({
id: n.id,
agent: toMobileAgent(n),
x: n.position?.x ?? 0,
y: n.position?.y ?? 0,
parentId: n.data.parentId ?? null,
}));
if (items.length === 0) return [] as Array<{ agent: MobileAgent; x: number; y: number; parentId: string | null }>;
const xs = items.map((i) => i.x);
const ys = items.map((i) => i.y);
const xMin = Math.min(...xs);
const xMax = Math.max(...xs);
const yMin = Math.min(...ys);
const yMax = Math.max(...ys);
const spread = (xMax - xMin) + (yMax - yMin);
if (spread < 1) {
// Degenerate (everything stacked) — fall back to a ring.
const n = items.length;
return items.map((it, idx) => {
const angle = (idx / n) * Math.PI * 2;
return {
agent: it.agent,
parentId: it.parentId,
x: 50 + Math.cos(angle) * 32,
y: 50 + Math.sin(angle) * 26,
};
});
}
const scaleX = (v: number) =>
xMax === xMin ? 50 : 8 + ((v - xMin) / (xMax - xMin)) * 84;
const scaleY = (v: number) =>
yMax === yMin ? 50 : 14 + ((v - yMin) / (yMax - yMin)) * 70;
return items.map((it) => ({
agent: it.agent,
parentId: it.parentId,
x: scaleX(it.x),
y: scaleY(it.y),
}));
}, [nodes]);
// Edges = parent→child relations from the store.
const edges = useMemo(() => {
const byId = new Map(layout.map((l) => [l.agent.id, l]));
return layout
.filter((l) => l.parentId && byId.has(l.parentId))
.map((l) => ({ from: byId.get(l.parentId!)!, to: l }));
}, [layout]);
// Pinch-to-zoom + single-finger pan over the graph layer. Header pill,
// legend, and FAB stay anchored to the viewport (outside the transform
// layer). Tap-to-open still works because a stationary touchend
// dispatches a click on the underlying button.
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const gestureRef = useRef<Gesture>({ kind: "none" });
const onTouchStart = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) {
const a = e.touches[0];
const b = e.touches[1];
gestureRef.current = {
kind: "pinch",
startDist: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY),
startScale: scale,
};
} else if (e.touches.length === 1) {
const t = e.touches[0];
gestureRef.current = {
kind: "pan",
startTouch: { x: t.clientX, y: t.clientY },
startPan: { ...pan },
};
}
};
const onTouchMove = (e: ReactTouchEvent<HTMLDivElement>) => {
const g = gestureRef.current;
if (g.kind === "pinch" && e.touches.length === 2 && g.startDist && g.startScale) {
const a = e.touches[0];
const b = e.touches[1];
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
setScale(clamp(g.startScale * (dist / g.startDist), SCALE_MIN, SCALE_MAX));
} else if (g.kind === "pan" && e.touches.length === 1 && g.startTouch && g.startPan) {
const t = e.touches[0];
setPan({
x: g.startPan.x + (t.clientX - g.startTouch.x),
y: g.startPan.y + (t.clientY - g.startTouch.y),
});
}
};
const onTouchEnd = (e: ReactTouchEvent<HTMLDivElement>) => {
if (e.touches.length === 0) gestureRef.current = { kind: "none" };
};
const resetView = () => {
setScale(1);
setPan({ x: 0, y: 0 });
};
const transformStyle = {
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "50% 50%",
// Smooth out the pinch math without lagging the gesture; tighter
// than a CSS animation so it doesn't feel rubber-bandy.
willChange: "transform",
};
const zoomed = Math.abs(scale - 1) > 0.01 || pan.x !== 0 || pan.y !== 0;
return (
<div
style={{
position: "absolute",
inset: 0,
background: p.bg,
overflow: "hidden",
fontFamily: MOBILE_FONT_SANS,
// Tell the browser we own touch gestures here — without this, the
// browser performs default pinch-to-zoom on the page itself,
// which would zoom the entire phone shell, not just our graph.
touchAction: "none",
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Dotted grid background — fills the viewport, doesn't transform */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `radial-gradient(${dark ? "rgba(255,255,255,0.05)" : "rgba(40,30,20,0.07)"} 1px, transparent 1px)`,
backgroundSize: "18px 18px",
}}
/>
{/* Header pill */}
<div
style={{
position: "absolute",
top: "max(env(safe-area-inset-top), 44px)",
left: 0,
right: 0,
zIndex: 20,
display: "flex",
justifyContent: "center",
padding: "0 12px",
}}
>
<WorkspacePill dark={dark} count={nodes.length} />
</div>
{/* Reset-view button — only shown after the user has zoomed or
panned, so the corner stays clean by default. Sits next to the
legend so it doesn't fight the spawn FAB. */}
{zoomed && (
<button
type="button"
onClick={resetView}
aria-label="Reset zoom"
style={{
position: "absolute",
right: 14,
top: "calc(max(env(safe-area-inset-top), 44px) + 56px)",
zIndex: 25,
padding: "6px 12px",
borderRadius: 999,
cursor: "pointer",
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
backdropFilter: "blur(20px)",
border: `0.5px solid ${p.border}`,
color: p.text2,
fontSize: 11,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.04em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
Reset
</button>
)}
{/* Transform layer — pinch-zoom + pan apply here. Edges and nodes
live inside so they scale together; everything outside this
layer (header, legend, FAB) is anchored to the viewport. */}
<div
style={{
position: "absolute",
inset: 0,
...transformStyle,
}}
>
{/* SVG edges */}
<svg
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
{edges.map((e, i) => (
<line
key={i}
x1={`${e.from.x}%`}
y1={`${e.from.y}%`}
x2={`${e.to.x}%`}
y2={`${e.to.y}%`}
stroke={dark ? "rgba(255,255,255,0.12)" : "rgba(40,30,20,0.12)"}
strokeWidth={1 / scale}
strokeDasharray="2 4"
/>
))}
</svg>
{/* Nodes */}
{layout.map((l) => {
const isOnline = l.agent.status === "online";
return (
<button
key={l.agent.id}
type="button"
onClick={() => onOpen(l.agent.id)}
style={{
position: "absolute",
left: `${l.x}%`,
top: `${l.y}%`,
transform: "translate(-50%, -50%)",
width: 130,
maxWidth: "42%",
background:
l.agent.tier === "T4" && isOnline
? p.t4SoftCard
: isOnline
? p.greenSoft
: p.surface,
border: `0.5px solid ${p.border}`,
borderRadius: 12,
padding: "8px 10px",
display: "flex",
flexDirection: "column",
gap: 4,
cursor: "pointer",
textAlign: "left",
boxShadow: dark
? "0 4px 14px rgba(0,0,0,0.3)"
: "0 2px 8px rgba(40,30,20,0.06)",
zIndex: 5,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={l.agent.status} size={7} dark={dark} halo={false} />
<span
style={{
flex: 1,
fontSize: 12,
fontWeight: 600,
color: p.text,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{l.agent.name}
</span>
<TierChip tier={l.agent.tier} dark={dark} />
</div>
<div
style={{
fontSize: 9,
color: p.text3,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
}}
>
{l.agent.tag}
</div>
</button>
);
})}
</div>
{/* End transform layer */}
{/* Bottom legend */}
<div
style={{
position: "absolute",
left: 14,
bottom: 96,
zIndex: 25,
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
backdropFilter: "blur(20px)",
border: `0.5px solid ${p.border}`,
borderRadius: 14,
padding: "10px 12px",
boxShadow: "0 4px 14px rgba(40,30,20,0.08)",
fontFamily: MOBILE_FONT_MONO,
fontSize: 9.5,
color: p.text2,
letterSpacing: "0.04em",
}}
>
<div
style={{
fontWeight: 600,
color: p.text3,
marginBottom: 6,
textTransform: "uppercase",
}}
>
Legend
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", maxWidth: 180 }}>
{(["online", "starting", "degraded", "failed", "paused"] as const).map((s) => (
<span key={s} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<StatusDot status={s} size={6} dark={dark} halo={false} />
{s}
</span>
))}
</div>
</div>
{/* Spawn FAB */}
<button
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
style={{
position: "absolute",
right: 24,
bottom: 100,
zIndex: 25,
width: 54,
height: 54,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px rgba(40,30,20,0.25)",
}}
>
{Icons.plus({ size: 22 })}
</button>
</div>
);
}
-493
View File
@@ -1,493 +0,0 @@
"use client";
// 04 · Chat — message thread + composer + sub-tabs.
// Wired to the same /workspaces/:id/a2a (method message/send) endpoint
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
};
type SubTab = "my" | "a2a";
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
onBack,
}: {
agentId: string;
dark: boolean;
onBack: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
storedMessages.map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
// a 5-line cap. Beyond the cap, internal scroll kicks in.
useEffect(() => {
const el = composerRef.current;
if (!el) return;
el.style.height = "auto";
const next = Math.min(el.scrollHeight, 132); // ~5 lines at 14.5px/1.4
el.style.height = `${next}px`;
}, [draft]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
if (!node) {
return (
<div
style={{
height: "100%",
background: p.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text3,
fontSize: 13,
fontFamily: MOBILE_FONT_SANS,
}}
>
Agent not found.
</div>
);
}
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const send = async () => {
const text = draft.trim();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
background: p.bg,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Header */}
<div
style={{
padding: "max(env(safe-area-inset-top), 44px) 14px 10px",
borderBottom: `0.5px solid ${p.divider}`,
background: dark ? "rgba(21,20,15,0.85)" : "rgba(246,244,239,0.85)",
backdropFilter: "blur(14px)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
type="button"
onClick={onBack}
aria-label="Back"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.back({ size: 18 })}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={a.status} size={7} dark={dark} halo={false} />
<span
style={{
fontSize: 15,
fontWeight: 600,
color: p.text,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{a.name}
</span>
<TierChip tier={a.tier} dark={dark} />
</div>
<div
style={{
fontSize: 11,
color: p.text3,
marginTop: 2,
fontFamily: MOBILE_FONT_MONO,
}}
>
{a.runtime} · {a.skills} skills
</div>
</div>
<button
type="button"
aria-label="More"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.more({ size: 18 })}
</button>
</div>
{/* Sub-tabs */}
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "4px 0 8px",
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: "auto",
padding: "14px 14px 16px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{tab === "a2a" && (
<div
style={{
padding: "20px 4px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
)}
{tab === "my" &&
messages.map((m) => {
const mine = m.role === "user";
return (
<div
key={m.id}
style={{
display: "flex",
justifyContent: mine ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "78%",
background: mine ? p.accent : dark ? "#22211c" : "#fff",
color: mine ? "#fff" : p.text,
border: mine ? "none" : `0.5px solid ${p.border}`,
borderRadius: mine ? "18px 18px 4px 18px" : "18px 18px 18px 4px",
padding: "9px 13px",
fontSize: 14.5,
lineHeight: 1.4,
overflowWrap: "anywhere",
}}
>
{m.text}
<div
style={{
fontSize: 10,
marginTop: 4,
opacity: mine ? 0.75 : 0.5,
fontFamily: MOBILE_FONT_MONO,
}}
>
{m.ts}
</div>
</div>
</div>
);
})}
{error && (
<div
role="alert"
style={{
alignSelf: "center",
padding: "6px 12px",
borderRadius: 12,
background: `${p.failed}1a`,
color: p.failed,
fontSize: 12,
}}
>
{error}
</div>
)}
</div>
{/* Footer ID */}
<div
style={{
padding: "0 14px 6px",
textAlign: "center",
fontFamily: MOBILE_FONT_MONO,
fontSize: 9.5,
color: p.text3,
letterSpacing: "0.04em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{agentId}
</div>
{/* Composer */}
<div
style={{
padding: "10px 12px max(env(safe-area-inset-bottom), 16px)",
borderTop: `0.5px solid ${p.divider}`,
background: dark ? "rgba(21,20,15,0.92)" : "rgba(246,244,239,0.92)",
backdropFilter: "blur(14px)",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 8,
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
borderRadius: 22,
padding: "6px 6px 6px 12px",
}}
>
<button
type="button"
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.attach({ size: 16 })}
</button>
<textarea
ref={composerRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter sends; Shift+Enter inserts a newline. Skip when the
// IME is composing — pressing Enter to commit a Chinese/
// Japanese candidate would otherwise dispatch the half-typed
// message (the same regression the desktop ChatTab guards).
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
e.keyCode !== 229
) {
e.preventDefault();
send();
}
}}
placeholder={reachable ? "Send a message…" : `Agent is ${a.status}`}
disabled={!reachable}
rows={1}
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
fontSize: 14.5,
lineHeight: 1.4,
color: p.text,
padding: "6px 0",
fontFamily: "inherit",
minWidth: 0,
resize: "none",
maxHeight: 132,
overflowY: "auto",
}}
/>
<button
type="button"
onClick={send}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.send({ size: 16 })}
</button>
</div>
</div>
</div>
);
}
@@ -1,368 +0,0 @@
"use client";
// 05 · Comms feed — workspace-wide A2A traffic.
// Bootstraps from /workspaces/:id/activity for the first few online
// workspaces, then prepends ACTIVITY_LOGGED events from the live socket.
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { useCanvasStore } from "@/store/canvas";
import { WorkspacePill } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { SectionLabel } from "./primitives";
interface CommItem {
id: string;
from: string;
to: string;
kind: string;
status: "ok" | "err";
summary: string;
durationMs: number | null;
ago: string;
ts: number;
}
interface ActivityRecord {
id: string;
workspace_id: string;
activity_type: string;
source_id: string | null;
target_id: string | null;
summary: string | null;
status: string;
duration_ms: number | null;
created_at: string;
}
const FAN_OUT_CAP = 4;
const RENDER_CAP = 30;
type FilterId = "all" | "errors";
function relativeAgo(iso: string): string {
const t = Date.parse(iso);
if (isNaN(t)) return "";
const seconds = Math.max(0, Math.round((Date.now() - t) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
export function MobileComms({ dark }: { dark: boolean }) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
const [items, setItems] = useState<CommItem[]>([]);
const [filter, setFilter] = useState<FilterId>("all");
const [loading, setLoading] = useState(true);
const nameOf = useCallback(
(id: string | null | undefined): string => {
if (!id) return "Unknown";
const n = nodes.find((x) => x.id === id);
return n?.data.name ?? id.slice(0, 8);
},
[nodes],
);
const toItem = useCallback(
(a: ActivityRecord): CommItem => ({
id: a.id,
from: nameOf(a.source_id ?? a.workspace_id),
to: nameOf(a.target_id),
kind: a.activity_type,
status: a.status === "error" || a.status === "err" ? "err" : "ok",
summary: a.summary ?? "",
durationMs: a.duration_ms,
ago: relativeAgo(a.created_at),
ts: Date.parse(a.created_at) || Date.now(),
}),
[nameOf],
);
// Stable signature of the online-workspace set. Re-runs the bootstrap
// only when which workspaces are online changes — not on every node
// position update or unrelated data churn.
const onlineWorkspaceIds = useMemo(
() =>
nodes
.filter((n) => n.data.status === "online")
.slice(0, FAN_OUT_CAP)
.map((n) => n.id),
[nodes],
);
const onlineSignature = onlineWorkspaceIds.join("|");
// Bootstrap: pull the most recent activity from the first few online
// workspaces. Identical fan-out cap to CommunicationOverlay to keep
// the load profile predictable on big tenants.
useEffect(() => {
let cancelled = false;
if (onlineWorkspaceIds.length === 0) {
setLoading(false);
return;
}
Promise.all(
onlineWorkspaceIds.map((id) =>
api.get<ActivityRecord[]>(`/workspaces/${id}/activity?limit=8`).catch(() => []),
),
).then((batches) => {
if (cancelled) return;
const flat = batches.flat().map(toItem);
flat.sort((a, b) => b.ts - a.ts);
setItems(flat.slice(0, RENDER_CAP));
setLoading(false);
});
return () => {
cancelled = true;
};
// Effect depends on the signature string (stable when the id set
// doesn't change) + toItem (memoized via useCallback). Listing the
// id-array directly would re-run on every render because the array
// identity changes even when the contents don't.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onlineSignature, toItem]);
// Live: prepend ACTIVITY_LOGGED events as they arrive.
useSocketEvent((msg) => {
if (msg.event !== "ACTIVITY_LOGGED") return;
const payload = msg.payload as Partial<ActivityRecord> | undefined;
if (!payload || !payload.id) return;
const rec: ActivityRecord = {
id: payload.id,
workspace_id: payload.workspace_id ?? msg.workspace_id ?? "",
activity_type: payload.activity_type ?? "a2a",
source_id: payload.source_id ?? null,
target_id: payload.target_id ?? null,
summary: payload.summary ?? null,
status: payload.status ?? "ok",
duration_ms: payload.duration_ms ?? null,
created_at: payload.created_at ?? new Date().toISOString(),
};
setItems((prev) => [toItem(rec), ...prev.filter((x) => x.id !== rec.id)].slice(0, RENDER_CAP));
});
const filtered = useMemo(
() => items.filter((c) => filter === "all" || c.status === "err"),
[items, filter],
);
const errCount = useMemo(() => items.filter((c) => c.status === "err").length, [items]);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 16px 8px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 14,
}}
>
<WorkspacePill dark={dark} count={nodes.length} />
{/* Header filter button reserved — the All/Errors chips below
already cover the v1 filter axis. */}
</div>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Comms
</h1>
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
}}
>
{items.length} events
</span>
</div>
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
Live A2A traffic across the workspace.
</p>
</div>
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
{(
[
{ id: "all", label: "All", n: items.length },
{ id: "errors", label: "Errors", n: errCount },
] as const
).map((o) => {
const on = filter === o.id;
return (
<button
key={o.id}
type="button"
onClick={() => setFilter(o.id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 999,
cursor: "pointer",
background: on ? p.text : dark ? "#22211c" : "#fff",
color: on ? (dark ? p.bg : "#fff") : p.text,
border: `0.5px solid ${on ? "transparent" : p.border}`,
fontSize: 13,
fontWeight: 500,
}}
>
{o.label}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
{o.n}
</span>
</button>
);
})}
</div>
<SectionLabel dark={dark}>Communications</SectionLabel>
<div style={{ padding: "0 14px", display: "flex", flexDirection: "column", gap: 8 }}>
{loading && items.length === 0 ? (
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading recent comms
</div>
) : filtered.length === 0 ? (
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
No A2A traffic yet.
</div>
) : (
filtered.map((c) => <CommRow key={c.id} c={c} dark={dark} />)
)}
</div>
</div>
);
}
function CommRow({ c, dark }: { c: CommItem; dark: boolean }) {
const p = usePalette(dark);
const isErr = c.status === "err";
return (
<div
style={{
background: p.surface,
borderRadius: 14,
border: `0.5px solid ${p.border}`,
padding: "12px 14px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
color: p.text,
}}
>
<span
style={{
padding: "1px 6px",
borderRadius: 4,
background: isErr ? "#f5dad2" : "#dde9e1",
color: isErr ? "#a8341a" : p.greenInk,
fontFamily: MOBILE_FONT_MONO,
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.06em",
}}
>
{isErr ? "ERR" : "OK"}
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 110,
}}
>
{c.from}
</span>
<span style={{ color: p.text3, fontWeight: 500 }}></span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 110,
}}
>
{c.to}
</span>
<span
style={{
marginLeft: "auto",
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
}}
>
{c.ago}
</span>
</div>
<div
style={{
fontSize: 11,
color: p.text3,
fontWeight: 600,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
}}
>
{c.kind}
{c.durationMs != null && (
<span style={{ marginLeft: 8, color: isErr ? "#a8341a" : p.text3 }}>{c.durationMs}ms</span>
)}
</div>
{c.summary && (
<div
style={{
fontSize: 12.5,
color: p.text2,
lineHeight: 1.4,
overflowWrap: "anywhere",
}}
>
{c.summary}
</div>
)}
</div>
);
}
@@ -1,589 +0,0 @@
"use client";
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { RemoteBadge, toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
type TabId = "overview" | "activity" | "config" | "memory";
const TABS: { id: TabId; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "activity", label: "Activity" },
{ id: "config", label: "Config" },
{ id: "memory", label: "Memory" },
];
export function MobileDetail({
agentId,
dark,
onBack,
onChat,
}: {
agentId: string;
dark: boolean;
onBack: () => void;
onChat: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [tab, setTab] = useState<TabId>("overview");
if (!node) {
return (
<div
style={{
height: "100%",
background: p.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text3,
fontSize: 13,
fontFamily: MOBILE_FONT_SANS,
}}
>
Agent not found.
</div>
);
}
const a = toMobileAgent(node);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Top bar */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 10,
padding: "max(env(safe-area-inset-top), 44px) 14px 0",
background: p.bg,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<button
type="button"
onClick={onBack}
aria-label="Back"
style={iconButtonStyle(p, dark)}
>
{Icons.back({ size: 18 })}
</button>
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
{Icons.more({ size: 18 })}
</button>
</div>
</div>
{/* Hero */}
<div style={{ padding: "20px 20px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<StatusDot status={a.status} size={10} dark={dark} />
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.greenInk,
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
{a.status}
</span>
{a.remote && <RemoteBadge palette={p} />}
</div>
<h1
style={{
margin: 0,
fontSize: 28,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.02em",
}}
>
{a.name}
</h1>
<p
style={{
margin: "6px 0 0",
fontSize: 14,
color: p.text2,
fontFamily: MOBILE_FONT_MONO,
}}
>
{a.tag}
</p>
</div>
{/* Stat pills */}
<div
style={{
display: "flex",
gap: 6,
padding: "0 16px 16px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
<PillStat label="TIER" value={a.tier} accent={p.t4Ink} dark={dark} chip="tier" />
<PillStat label="RUNTIME" value={a.runtime} dark={dark} />
<PillStat label="SKILLS" value={a.skills} dark={dark} />
<PillStat label="STATUS" value={a.status} accent={p.online} dark={dark} dot />
</div>
{/* Description card */}
{a.desc && (
<div style={{ padding: "0 14px" }}>
<div
style={{
background: p.surface,
borderRadius: 16,
border: `0.5px solid ${p.border}`,
padding: "14px 16px",
}}
>
<p style={{ margin: 0, fontSize: 14.5, lineHeight: 1.5, color: p.text }}>{a.desc}</p>
</div>
</div>
)}
{/* Tabs */}
<div
style={{
display: "flex",
gap: 4,
padding: "20px 14px 10px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{TABS.map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "8px 14px",
borderRadius: 999,
border: "none",
cursor: "pointer",
background: on ? p.text : "transparent",
color: on ? (dark ? p.bg : "#fff") : p.text2,
fontSize: 13,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{t.label}
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ padding: "0 14px" }}>
{tab === "overview" && <DetailOverview a={a} dark={dark} />}
{tab === "activity" && <DetailActivity workspaceId={a.id} dark={dark} />}
{tab === "config" && <DetailConfig a={a} dark={dark} />}
{tab === "memory" && <DetailMemory dark={dark} />}
</div>
{/* Chat CTA */}
<div style={{ position: "absolute", left: 14, right: 14, bottom: 92, zIndex: 28 }}>
<button
type="button"
onClick={onChat}
style={{
width: "100%",
height: 52,
borderRadius: 16,
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
border: "none",
fontSize: 15,
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
}}
>
{Icons.chat({ size: 18 })} Open chat
</button>
</div>
</div>
);
}
function iconButtonStyle(p: MobilePalette, dark: boolean) {
return {
width: 36,
height: 36,
borderRadius: 999,
cursor: "pointer",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: p.text2,
} as const;
}
function PillStat({
label,
value,
accent,
dark,
dot,
chip,
}: {
label: string;
value: string | number;
accent?: string;
dark: boolean;
dot?: boolean;
chip?: "tier";
}) {
const p = usePalette(dark);
const active = !!accent;
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "7px 12px",
borderRadius: 999,
flexShrink: 0,
background: active ? `${accent}1a` : dark ? "#22211c" : "#fff",
border: `0.5px solid ${active ? `${accent}40` : p.border}`,
}}
>
<span
style={{
fontSize: 9.5,
color: active ? accent : p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
{label}
</span>
{dot && <StatusDot status="online" size={6} dark={dark} halo={false} />}
{chip === "tier" ? (
<TierChip tier={value as "T1" | "T2" | "T3" | "T4"} dark={dark} />
) : (
<span
style={{
fontSize: 12,
color: active ? accent : p.text,
fontWeight: 600,
textTransform: label === "STATUS" ? "capitalize" : "none",
}}
>
{value}
</span>
)}
</div>
);
}
function DetailOverview({
a,
dark,
}: {
a: ReturnType<typeof toMobileAgent>;
dark: boolean;
}) {
const p = usePalette(dark);
const Row = ({ k, v, mono = true }: { k: string; v: string; mono?: boolean }) => (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 0",
borderBottom: `0.5px solid ${p.divider}`,
}}
>
<span
style={{
fontSize: 11.5,
color: p.text3,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
textTransform: "uppercase",
}}
>
{k}
</span>
<span
style={{
fontSize: 13,
color: p.text,
fontWeight: 500,
fontFamily: mono ? MOBILE_FONT_MONO : "inherit",
maxWidth: "60%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v}
</span>
</div>
);
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "4px 16px",
border: `0.5px solid ${p.border}`,
}}
>
<Row k="ID" v={a.id} />
<Row k="Tier" v={a.tier} />
<Row k="Runtime" v={a.runtime} />
<Row k="Active tasks" v={String(a.calls)} />
<Row k="Skills" v={`${a.skills} loaded`} />
<Row k="Origin" v={a.remote ? "remote" : "platform"} />
</div>
);
}
interface ActivityRecord {
id: string;
activity_type: string;
status: string;
summary: string | null;
duration_ms: number | null;
created_at: string;
}
function DetailActivity({ workspaceId, dark }: { workspaceId: string; dark: boolean }) {
const p = usePalette(dark);
const [items, setItems] = useState<ActivityRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
setItems(null);
api
.get<ActivityRecord[]>(`/workspaces/${workspaceId}/activity?limit=12`)
.then((rows) => {
if (!cancelled) setItems(rows);
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load activity");
setItems([]);
}
});
return () => {
cancelled = true;
};
}, [workspaceId]);
if (items === null) {
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "20px 16px",
border: `0.5px solid ${p.border}`,
color: p.text3,
fontSize: 13,
}}
>
Loading activity
</div>
);
}
if (items.length === 0) {
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "20px 16px",
border: `0.5px solid ${p.border}`,
color: p.text3,
fontSize: 13,
}}
>
{error ?? "No recent activity. New events appear here as the agent reports them."}
</div>
);
}
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "6px 16px",
border: `0.5px solid ${p.border}`,
}}
>
{items.map((it, i) => {
const ts = new Date(it.created_at);
const label = isNaN(ts.getTime())
? ""
: ts.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
const isErr = it.status === "error" || it.status === "err";
return (
<div
key={it.id}
style={{
display: "flex",
gap: 12,
padding: "12px 0",
borderBottom: i < items.length - 1 ? `0.5px solid ${p.divider}` : "none",
}}
>
<span
style={{
fontSize: 11,
color: p.text3,
paddingTop: 2,
width: 48,
fontFamily: MOBILE_FONT_MONO,
flexShrink: 0,
}}
>
{label}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
marginBottom: 2,
}}
>
<span
style={{
padding: "1px 5px",
borderRadius: 4,
background: isErr ? "#f5dad2" : "#dde9e1",
color: isErr ? "#a8341a" : p.greenInk,
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.06em",
}}
>
{isErr ? "ERR" : "OK"}
</span>
<span>{it.activity_type}</span>
{it.duration_ms != null && <span>· {it.duration_ms}ms</span>}
</div>
{it.summary && (
<span
style={{
fontSize: 13.5,
color: p.text,
lineHeight: 1.45,
overflowWrap: "anywhere",
}}
>
{it.summary}
</span>
)}
</div>
</div>
);
})}
</div>
);
}
function DetailConfig({
a,
dark,
}: {
a: ReturnType<typeof toMobileAgent>;
dark: boolean;
}) {
const p = usePalette(dark);
const cfg = JSON.stringify(
{
tier: a.tier,
runtime: a.runtime,
skills: a.skills,
remote: a.remote,
},
null,
2,
);
return (
<pre
style={{
background: dark ? "#0f0e0a" : "#fff",
borderRadius: 16,
padding: "14px 16px",
border: `0.5px solid ${p.border}`,
fontFamily: MOBILE_FONT_MONO,
fontSize: 11.5,
lineHeight: 1.55,
color: p.text2,
margin: 0,
overflow: "auto",
whiteSpace: "pre-wrap",
}}
>
{cfg}
</pre>
);
}
function DetailMemory({ dark }: { dark: boolean }) {
const p = usePalette(dark);
return (
<div
style={{
background: p.surface,
borderRadius: 16,
padding: "14px 16px",
border: `0.5px solid ${p.border}`,
fontSize: 13,
color: p.text2,
lineHeight: 1.5,
}}
>
<span style={{ color: p.text }}>Ephemeral session.</span> Memory clears on workspace
restart. Open the desktop canvas for the full memory inspector.
</div>
);
}
-208
View File
@@ -1,208 +0,0 @@
"use client";
// 01 · Workspace home — agent list + filter chips + FAB.
// Mirrors design/screen-home.jsx, swapped to live store data.
import { useMemo, useState } from "react";
import { useCanvasStore } from "@/store/canvas";
import {
type AgentFilter,
AgentCard,
FilterChips,
WorkspacePill,
classifyForFilter,
toMobileAgent,
} from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, SectionLabel } from "./primitives";
export function MobileHome({
dark,
density,
onOpen,
onSpawn,
workspaceLabel = "Default",
username,
}: {
dark: boolean;
density: "compact" | "regular";
onOpen: (agentId: string) => void;
onSpawn: () => void;
workspaceLabel?: string;
username?: string;
}) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
const agents = useMemo(() => nodes.map(toMobileAgent), [nodes]);
const [filter, setFilter] = useState<AgentFilter>("all");
const counts = useMemo(() => {
const c = { all: agents.length, online: 0, issue: 0, paused: 0 };
for (const a of agents) {
const bucket = classifyForFilter(a.status);
if (bucket !== "all") c[bucket]++;
}
return c;
}, [agents]);
const filtered = useMemo(
() => agents.filter((a) => filter === "all" || classifyForFilter(a.status) === filter),
[agents, filter],
);
const compact = density === "compact";
const rootCount = useMemo(
() => agents.filter((a) => !a.parentId).length,
[agents],
);
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
{/* Sticky header */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 10,
background: `linear-gradient(${p.bg} 60%, ${p.bg}00)`,
padding: "max(env(safe-area-inset-top), 44px) 16px 8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 14,
}}
>
<WorkspacePill dark={dark} count={agents.length} />
{/* Search button reserved — wire to a mobile SearchDialog in v1.1. */}
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 4,
}}
>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Agents
</h1>
{username && (
<span
style={{
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
letterSpacing: "0.04em",
}}
>
{username}
</span>
)}
</div>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: p.text2 }}>
{rootCount} workspace{rootCount === 1 ? "" : "s"} · live
</p>
</div>
<FilterChips value={filter} onChange={setFilter} dark={dark} counts={counts} />
<SectionLabel
dark={dark}
right={
<span
style={{
color: p.text3,
fontSize: 10.5,
letterSpacing: "0.04em",
textTransform: "none",
}}
>
{filtered.length}/{agents.length}
</span>
}
>
Workspace · {workspaceLabel}
</SectionLabel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
padding: "0 14px",
}}
>
{filtered.length === 0 ? (
<div
style={{
padding: "40px 8px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
No agents match this filter.
</div>
) : (
filtered.map((a) => (
<AgentCard
key={a.id}
agent={a}
dark={dark}
compact={compact}
onClick={() => onOpen(a.id)}
/>
))
)}
</div>
{/* Spawn FAB */}
<button
type="button"
onClick={onSpawn}
aria-label="Spawn new agent"
style={{
position: "absolute",
right: 24,
bottom: 100,
zIndex: 25,
width: 54,
height: 54,
borderRadius: 999,
border: "none",
cursor: "pointer",
background: p.text,
color: dark ? p.bg : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
}}
>
{Icons.plus({ size: 22 })}
</button>
</div>
);
}
-194
View File
@@ -1,194 +0,0 @@
"use client";
// "Me" tab — the prototype design didn't ship a Me screen, so this is
// the natural mobile home for theme + accent + density preferences
// (the prototype's floating Tweaks panel collapses into this tab here).
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { SectionLabel } from "./primitives";
const ACCENTS = ["#2f9e6a", "#3b6fe0", "#7a4dd1", "#d97757", "#1f8a8a"] as const;
export function MobileMe({
dark,
accent,
setAccent,
density,
setDensity,
}: {
dark: boolean;
accent: string;
setAccent: (v: string) => void;
density: "compact" | "regular";
setDensity: (v: "compact" | "regular") => void;
}) {
const p = usePalette(dark);
const { theme, setTheme } = useTheme();
return (
<div
style={{
height: "100%",
overflow: "auto",
background: p.bg,
paddingBottom: 96,
fontFamily: MOBILE_FONT_SANS,
}}
>
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 20px 8px" }}>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.025em",
}}
>
Me
</h1>
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
Theme, accent, and layout density.
</p>
</div>
<SectionLabel dark={dark}>Theme</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<SegmentedRow
options={[
{ id: "system", label: "System" },
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
]}
value={theme}
onChange={(v) => setTheme(v as ThemePreference)}
palette={p}
dark={dark}
/>
</Card>
</div>
<SectionLabel dark={dark}>Accent</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<div style={{ display: "flex", gap: 12, padding: "12px 4px", flexWrap: "wrap" }}>
{ACCENTS.map((c) => {
const on = c === accent;
return (
<button
key={c}
type="button"
onClick={() => setAccent(c)}
aria-label={`Set accent ${c}`}
style={{
width: 36,
height: 36,
borderRadius: 999,
cursor: "pointer",
background: c,
border: on ? `2px solid ${p.text}` : "2px solid transparent",
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
}}
/>
);
})}
</div>
</Card>
</div>
<SectionLabel dark={dark}>Density</SectionLabel>
<div style={{ padding: "0 14px" }}>
<Card palette={p}>
<SegmentedRow
options={[
{ id: "regular", label: "Regular" },
{ id: "compact", label: "Compact" },
]}
value={density}
onChange={(v) => setDensity(v as "regular" | "compact")}
palette={p}
dark={dark}
/>
</Card>
</div>
<div
style={{
padding: "24px 20px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text3,
letterSpacing: "0.04em",
}}
>
Mobile design preview · v0.1
</div>
</div>
);
}
function Card({
palette,
children,
}: {
palette: MobilePalette;
children: React.ReactNode;
}) {
return (
<div
style={{
background: palette.surface,
borderRadius: 16,
border: `0.5px solid ${palette.border}`,
padding: "4px 14px",
}}
>
{children}
</div>
);
}
function SegmentedRow({
options,
value,
onChange,
palette,
dark,
}: {
options: { id: string; label: string }[];
value: string;
onChange: (v: string) => void;
palette: MobilePalette;
dark: boolean;
}) {
return (
<div style={{ display: "flex", gap: 6, padding: "10px 0" }}>
{options.map((o) => {
const on = o.id === value;
return (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
style={{
flex: 1,
padding: "10px 8px",
borderRadius: 10,
cursor: "pointer",
background: on ? palette.text : "transparent",
color: on ? (dark ? palette.bg : "#fff") : palette.text,
border: `1px solid ${on ? "transparent" : palette.border}`,
fontSize: 13,
fontWeight: 600,
}}
>
{o.label}
</button>
);
})}
</div>
);
}
@@ -1,429 +0,0 @@
"use client";
// 06 · Spawn agent — bottom-sheet flow.
// Fetches /templates so the user picks from what's actually installed
// on this platform (no hardcoded ID guesswork). Posts to /workspaces
// with the same shape useTemplateDeploy uses. Skips the secret-key
// preflight — if a deploy needs missing keys, the API surfaces the
// error and we show it with a hint to fall through to the desktop
// dialog (which has the full preflight + key-import flow).
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { type Template } from "@/lib/deploy-preflight";
import { tierCode } from "./palette";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
import { Icons, SectionLabel, TierChip } from "./primitives";
const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
T1: "Sandboxed",
T2: "Standard",
T3: "Privileged",
T4: "Full Access",
};
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
const p = usePalette(dark);
const [templates, setTemplates] = useState<Template[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(true);
const [tplId, setTplId] = useState<string | null>(null);
const [tier, setTier] = useState<"T1" | "T2" | "T3" | "T4">("T2");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.get<Template[]>("/templates")
.then((list) => {
if (cancelled) return;
setTemplates(list);
if (list.length > 0) {
setTplId(list[0].id);
setTier(tierCode(list[0].tier));
}
})
.catch(() => {
if (!cancelled) setTemplates([]);
})
.finally(() => {
if (!cancelled) setLoadingTemplates(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSpawn = async () => {
if (busy || !tplId) return;
const chosen = templates.find((t) => t.id === tplId);
if (!chosen) return;
setError(null);
setBusy(true);
try {
await api.post<{ id: string }>("/workspaces", {
name: (name.trim() || chosen.name),
template: chosen.id,
tier: Number(tier.slice(1)),
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
},
});
onClose();
} catch (e) {
setError(
e instanceof Error
? `${e.message}. If this template needs missing API keys, use the desktop palette to import them.`
: "Spawn failed",
);
} finally {
setBusy(false);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spawn agent"
style={{
position: "absolute",
inset: 0,
zIndex: 100,
background: "rgba(20,15,10,0.42)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-end",
fontFamily: MOBILE_FONT_SANS,
}}
onClick={(e) => {
// Click on the dim backdrop closes the sheet.
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: "100%",
background: p.bg,
borderRadius: "24px 24px 0 0",
maxHeight: "88%",
overflow: "auto",
boxShadow: "0 -10px 40px rgba(0,0,0,0.18)",
}}
>
<Grabber palette={p} />
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 18px 10px",
}}
>
<div>
<h2
style={{
margin: 0,
fontSize: 22,
fontWeight: 700,
color: p.text,
letterSpacing: "-0.02em",
}}
>
Spawn Agent
</h2>
<p style={{ margin: "2px 0 0", fontSize: 12.5, color: p.text2 }}>
In workspace · Default
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 32,
height: 32,
borderRadius: 999,
cursor: "pointer",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
color: p.text2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.close({ size: 16 })}
</button>
</div>
{/* Templates */}
<SectionLabel dark={dark}>Template</SectionLabel>
<div style={{ padding: "0 14px" }}>
{loadingTemplates ? (
<div
style={{
padding: "24px 8px",
textAlign: "center",
color: p.text3,
fontSize: 13,
}}
>
Loading templates
</div>
) : templates.length === 0 ? (
<div
style={{
padding: "16px 14px",
background: p.surface,
borderRadius: 14,
border: `0.5px solid ${p.border}`,
color: p.text2,
fontSize: 13,
lineHeight: 1.45,
}}
>
No templates installed on this platform yet. Open the desktop canvas
and use the template palette to import one (Claude Code, Hermes, or
an org template), then come back here to spawn.
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
{templates.map((t) => {
const on = tplId === t.id;
const tCode = tierCode(t.tier);
return (
<button
key={t.id}
type="button"
onClick={() => {
setTplId(t.id);
setTier(tCode);
}}
style={{
background: on
? dark
? "#2a2823"
: "#fff"
: dark
? "#1d1c17"
: "#fbf9f4",
border: `1px solid ${on ? p.accent : p.border}`,
borderRadius: 14,
padding: "12px 12px",
textAlign: "left",
cursor: "pointer",
display: "flex",
flexDirection: "column",
gap: 4,
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6,
}}
>
<span
style={{
fontSize: 13.5,
fontWeight: 600,
color: p.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t.name}
</span>
<TierChip tier={tCode} dark={dark} />
</div>
{t.description && (
<span
style={{
fontSize: 11.5,
color: p.text2,
lineHeight: 1.35,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{t.description}
</span>
)}
{on && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
width: 16,
height: 16,
borderRadius: 999,
background: p.accent,
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons.check({ size: 10, sw: 2.5 })}
</span>
)}
</button>
);
})}
</div>
)}
</div>
{/* Name */}
<SectionLabel dark={dark}>Name</SectionLabel>
<div style={{ padding: "0 14px" }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={tplId
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
: "agent-name"}
style={{
width: "100%",
padding: "12px 14px",
background: dark ? "#22211c" : "#fff",
border: `0.5px solid ${p.border}`,
borderRadius: 12,
fontFamily: MOBILE_FONT_MONO,
fontSize: 13.5,
color: p.text,
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Tier */}
<SectionLabel dark={dark}>Permission tier</SectionLabel>
<div style={{ padding: "0 14px", display: "flex", gap: 6 }}>
{(["T1", "T2", "T3", "T4"] as const).map((t) => {
const on = tier === t;
return (
<button
key={t}
type="button"
onClick={() => setTier(t)}
style={{
flex: 1,
padding: "10px 8px",
cursor: "pointer",
background: on ? (dark ? "#22211c" : "#fff") : "transparent",
border: `1px solid ${on ? p.accent : p.border}`,
borderRadius: 12,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<TierChip tier={t} dark={dark} size="lg" />
<span style={{ fontSize: 10.5, color: p.text2, fontWeight: 500 }}>
{TIER_LABEL[t]}
</span>
</button>
);
})}
</div>
{/* Error */}
{error && (
<div
role="alert"
style={{
margin: "12px 14px 0",
padding: "10px 14px",
background: `${p.failed}1a`,
border: `0.5px solid ${p.failed}40`,
borderRadius: 12,
color: p.failed,
fontSize: 12.5,
lineHeight: 1.4,
}}
>
{error}
</div>
)}
{/* Spawn button */}
<div style={{ padding: "20px 14px max(env(safe-area-inset-bottom), 28px)" }}>
<button
type="button"
onClick={handleSpawn}
disabled={busy || !tplId || templates.length === 0}
style={{
width: "100%",
height: 52,
borderRadius: 16,
border: "none",
cursor: busy ? "wait" : tplId ? "pointer" : "not-allowed",
background: p.text,
color: dark ? p.bg : "#fff",
fontSize: 15,
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
opacity: busy || !tplId ? 0.55 : 1,
}}
>
{Icons.zap({ size: 16 })} {busy ? "Spawning…" : "Spawn agent"}
</button>
<p
style={{
margin: "10px 0 0",
textAlign: "center",
fontSize: 11.5,
color: p.text3,
lineHeight: 1.4,
}}
>
Boots in ~3s. Tier {tier} permissions apply on first call.
</p>
</div>
</div>
</div>
);
}
function Grabber({ palette }: { palette: MobilePalette }) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0 4px" }}>
<span
style={{
width: 38,
height: 4,
borderRadius: 999,
background: palette.text3,
opacity: 0.4,
}}
/>
</div>
);
}
@@ -1,211 +0,0 @@
// @vitest-environment jsdom
/**
* MobileApp route-state contract.
*
* The mobile shell uses local React state (not URL routing) for
* navigation between the 6 screens. This test pins the back-stack
* shape so a future refactor can't silently regress:
*
* home →(open agent)→ detail
* detail →(open chat)→ chat chat →(back)→ detail
* detail →(back)→ home
*
* home / canvas / comms / me — reachable via the bottom tab bar.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
beforeEach(() => {
// URL state persists across tests in jsdom — reset to a clean slate
// so each test starts on the home route regardless of what the
// previous test pushed onto the history stack.
window.history.replaceState(null, "", "/");
});
afterEach(() => {
cleanup();
});
// Mock the theme provider — MobileApp reads resolvedTheme to pick a
// palette; for routing we don't care which one, light is fine.
vi.mock("@/lib/theme-provider", () => ({
useTheme: () => ({ theme: "light", resolvedTheme: "light", setTheme: vi.fn() }),
}));
// Stub each screen to a sentinel that exposes the props MobileApp passes
// in. The whole point is to verify the routing handoff, not the screens
// themselves — those have their own tests.
vi.mock("../MobileHome", () => ({
MobileHome: ({ onOpen, onSpawn }: { onOpen: (id: string) => void; onSpawn: () => void }) => (
<div>
<span data-testid="screen">home</span>
<button onClick={() => onOpen("ws-42")}>open-ws-42</button>
<button onClick={onSpawn}>open-spawn</button>
</div>
),
}));
vi.mock("../MobileCanvas", () => ({
MobileCanvas: () => <span data-testid="screen">canvas</span>,
}));
vi.mock("../MobileDetail", () => ({
MobileDetail: ({
agentId,
onBack,
onChat,
}: {
agentId: string;
onBack: () => void;
onChat: () => void;
}) => (
<div>
<span data-testid="screen">detail:{agentId}</span>
<button onClick={onBack}>detail-back</button>
<button onClick={onChat}>detail-open-chat</button>
</div>
),
}));
vi.mock("../MobileChat", () => ({
MobileChat: ({ agentId, onBack }: { agentId: string; onBack: () => void }) => (
<div>
<span data-testid="screen">chat:{agentId}</span>
<button onClick={onBack}>chat-back</button>
</div>
),
}));
vi.mock("../MobileComms", () => ({
MobileComms: () => <span data-testid="screen">comms</span>,
}));
vi.mock("../MobileMe", () => ({
MobileMe: () => <span data-testid="screen">me</span>,
}));
vi.mock("../MobileSpawn", () => ({
MobileSpawn: ({ onClose }: { onClose: () => void }) => (
<div>
<span data-testid="spawn-sheet">spawn</span>
<button onClick={onClose}>spawn-close</button>
</div>
),
}));
// MobileApp's shared TabBar is the user's gateway to the Canvas / Comms /
// Me screens. Rather than depend on its visual icon set we expose a
// label-based stub so the test can call onChange directly.
vi.mock("../components", async () => {
const actual = await vi.importActual<typeof import("../components")>("../components");
type TabId = "agents" | "canvas" | "comms" | "me";
return {
...actual,
TabBar: ({ onChange }: { active: TabId; onChange: (id: TabId) => void }) => (
<div data-testid="tab-bar">
{(["agents", "canvas", "comms", "me"] as const).map((id) => (
<button key={id} onClick={() => onChange(id)}>
tab-{id}
</button>
))}
</div>
),
};
});
import { MobileApp } from "../MobileApp";
const visibleScreen = () =>
Array.from(document.querySelectorAll('[data-testid="screen"]'))
.map((el) => el.textContent ?? "")
.filter(Boolean);
describe("MobileApp — route state", () => {
it("starts on the home screen", () => {
render(<MobileApp />);
expect(visibleScreen()).toEqual(["home"]);
});
it("home → open agent → detail (passes agentId through)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
it("detail → open chat → chat (carries the same agentId)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
expect(visibleScreen()).toEqual(["chat:ws-42"]);
});
it("chat back returns to detail (NOT to home — preserves the back-stack)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
fireEvent.click(screen.getByText("chat-back"));
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
it("detail back returns to home", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-back"));
expect(visibleScreen()).toEqual(["home"]);
});
it("hides the tab bar on chat (per design — composer reclaims that space)", () => {
render(<MobileApp />);
expect(screen.queryByTestId("tab-bar")).not.toBeNull();
fireEvent.click(screen.getByText("open-ws-42"));
expect(screen.queryByTestId("tab-bar")).not.toBeNull(); // detail
fireEvent.click(screen.getByText("detail-open-chat"));
expect(screen.queryByTestId("tab-bar")).toBeNull(); // chat
});
it("tab bar switches the four primary screens (Agents / Canvas / Comms / Me)", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("tab-canvas"));
expect(visibleScreen()).toEqual(["canvas"]);
fireEvent.click(screen.getByText("tab-comms"));
expect(visibleScreen()).toEqual(["comms"]);
fireEvent.click(screen.getByText("tab-me"));
expect(visibleScreen()).toEqual(["me"]);
fireEvent.click(screen.getByText("tab-agents"));
expect(visibleScreen()).toEqual(["home"]);
});
it("spawn sheet overlays from anywhere, closes on dismiss", () => {
render(<MobileApp />);
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
fireEvent.click(screen.getByText("open-spawn"));
expect(screen.queryByTestId("spawn-sheet")).not.toBeNull();
fireEvent.click(screen.getByText("spawn-close"));
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
});
it("seeds initial route from ?m= and ?a= so deep links open the right screen", () => {
window.history.replaceState(null, "", "/?m=detail&a=ws-99");
render(<MobileApp />);
expect(visibleScreen()).toEqual(["detail:ws-99"]);
});
it("collapses ?m=detail without ?a to home (detail without an agent is meaningless)", () => {
window.history.replaceState(null, "", "/?m=detail");
render(<MobileApp />);
expect(visibleScreen()).toEqual(["home"]);
});
it("syncs in-app navigation to the URL so browser back leaves the mobile stack", () => {
render(<MobileApp />);
expect(window.location.search).toBe("");
fireEvent.click(screen.getByText("open-ws-42"));
expect(window.location.search).toBe("?m=detail&a=ws-42");
fireEvent.click(screen.getByText("detail-open-chat"));
expect(window.location.search).toBe("?m=chat&a=ws-42");
});
it("popstate (back button) restores the previous route", () => {
render(<MobileApp />);
fireEvent.click(screen.getByText("open-ws-42"));
fireEvent.click(screen.getByText("detail-open-chat"));
// Simulate browser back: rewind URL ourselves, then dispatch popstate.
window.history.replaceState(null, "", "/?m=detail&a=ws-42");
fireEvent.popState(window);
expect(visibleScreen()).toEqual(["detail:ws-42"]);
});
});
@@ -1,101 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Node } from "@xyflow/react";
import { type WorkspaceNodeData } from "@/store/canvas";
import { classifyForFilter, toMobileAgent } from "../components";
const baseData: WorkspaceNodeData = {
name: "test-agent",
status: "online",
tier: 2,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
currentTask: "",
runtime: "claude-code",
needsRestart: false,
budgetLimit: null,
};
const makeNode = (overrides: Partial<WorkspaceNodeData> = {}, id = "ws-1"): Node<WorkspaceNodeData> => ({
id,
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { ...baseData, ...overrides },
});
describe("toMobileAgent", () => {
it("maps name, status, tier, runtime through the design's 6-key palette", () => {
const a = toMobileAgent(makeNode({ status: "online", tier: 3, runtime: "hermes" }));
expect(a.name).toBe("test-agent");
expect(a.status).toBe("online");
expect(a.tier).toBe("T3");
expect(a.runtime).toBe("hermes");
expect(a.tag).toBe("hermes"); // tag mirrors runtime in v1
});
it("flags 'external' runtime as remote (drives the ★ REMOTE badge)", () => {
expect(toMobileAgent(makeNode({ runtime: "external" })).remote).toBe(true);
expect(toMobileAgent(makeNode({ runtime: "claude-code" })).remote).toBe(false);
});
it("falls back to 'unknown' runtime when both workspace + agentCard are blank", () => {
const a = toMobileAgent(makeNode({ runtime: "" }));
expect(a.runtime).toBe("unknown");
expect(a.tag).toBe("unknown");
});
it("uses workspace id as fallback name when name is missing", () => {
const a = toMobileAgent(makeNode({ name: "" }, "ws-fallback"));
expect(a.name).toBe("ws-fallback");
});
it("preserves the parent link so MobileCanvas can draw parent→child edges", () => {
const a = toMobileAgent(makeNode({ parentId: "ws-parent" }, "ws-child"));
expect(a.parentId).toBe("ws-parent");
});
it("maps platform 'provisioning' to design 'starting'", () => {
expect(toMobileAgent(makeNode({ status: "provisioning" })).status).toBe("starting");
});
it("counts skills from agentCard.skills array", () => {
const a = toMobileAgent(
makeNode({
agentCard: {
skills: [{ name: "skill-a" }, { name: "skill-b" }, { name: "skill-c" }],
},
}),
);
expect(a.skills).toBe(3);
});
it("reports 0 skills when agentCard is null", () => {
expect(toMobileAgent(makeNode({ agentCard: null })).skills).toBe(0);
});
});
describe("classifyForFilter", () => {
it("buckets online statuses to the Online filter", () => {
expect(classifyForFilter("online")).toBe("online");
});
it("buckets failure-state statuses to the Issues filter", () => {
// Issues = anything the user needs to look at NOW.
expect(classifyForFilter("failed")).toBe("issue");
expect(classifyForFilter("degraded")).toBe("issue");
});
it("buckets non-online non-failure statuses to the Paused filter", () => {
// Catch-all for transient or intentional offline states.
expect(classifyForFilter("paused")).toBe("paused");
expect(classifyForFilter("offline")).toBe("paused");
expect(classifyForFilter("starting")).toBe("paused");
});
});
@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import { MOL_DARK, MOL_LIGHT, getPalette, normalizeStatus, tierCode } from "../palette";
describe("normalizeStatus", () => {
it("passes design-known statuses through verbatim", () => {
expect(normalizeStatus("online")).toBe("online");
expect(normalizeStatus("degraded")).toBe("degraded");
expect(normalizeStatus("failed")).toBe("failed");
expect(normalizeStatus("paused")).toBe("paused");
expect(normalizeStatus("offline")).toBe("offline");
});
it("maps platform 'provisioning' to design 'starting'", () => {
// The platform's 14-state machine collapses to the design's 6 keys.
// 'provisioning' (post-spawn boot) is the same UX bucket as 'starting'.
expect(normalizeStatus("provisioning")).toBe("starting");
expect(normalizeStatus("starting")).toBe("starting");
});
it("maps unknown / null / empty to offline", () => {
expect(normalizeStatus(undefined)).toBe("offline");
expect(normalizeStatus(null)).toBe("offline");
expect(normalizeStatus("")).toBe("offline");
expect(normalizeStatus("garbage-status")).toBe("offline");
});
});
describe("tierCode", () => {
it("maps numeric tiers to T-codes", () => {
expect(tierCode(1)).toBe("T1");
expect(tierCode(2)).toBe("T2");
expect(tierCode(3)).toBe("T3");
expect(tierCode(4)).toBe("T4");
});
it("clamps below-1 to T1 (never below sandboxed)", () => {
expect(tierCode(0)).toBe("T1");
expect(tierCode(-5)).toBe("T1");
});
it("clamps above-4 to T4 (never above full-access)", () => {
expect(tierCode(5)).toBe("T4");
expect(tierCode(99)).toBe("T4");
});
it("falls back to T2 (Standard) on null/undefined", () => {
// T2 is the platform default for fresh agents — matches the
// CreateWorkspaceDialog default. Keeps the mobile spawn UX
// consistent with the desktop when tier metadata is missing.
expect(tierCode(undefined)).toBe("T2");
expect(tierCode(null)).toBe("T2");
});
});
describe("getPalette", () => {
it("returns the light palette when dark is false", () => {
expect(getPalette(false)).toBe(MOL_LIGHT);
});
it("returns the dark palette when dark is true", () => {
expect(getPalette(true)).toBe(MOL_DARK);
});
it("light + dark palettes have the same key set (no drift)", () => {
expect(Object.keys(MOL_LIGHT).sort()).toEqual(Object.keys(MOL_DARK).sort());
});
});
-444
View File
@@ -1,444 +0,0 @@
"use client";
// Screen-shared composites: TabBar, WorkspacePill, AgentCard, FilterChips.
// Mirrors molecules-ai-mobile-app/project/screens-shared.jsx but reads
// from the live canvas store rather than the prototype's mock AGENTS.
import type { Node } from "@xyflow/react";
import { type WorkspaceNodeData, summarizeWorkspaceCapabilities } from "@/store/canvas";
import {
MOBILE_FONT_MONO,
type MobilePalette,
type MobileStatus,
normalizeStatus,
tierCode,
usePalette,
} from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
// Derived view-model the mobile screens consume. Built once per render
// from the store's Node<WorkspaceNodeData>.
export interface MobileAgent {
id: string;
name: string;
tag: string;
tier: "T1" | "T2" | "T3" | "T4";
status: MobileStatus;
remote: boolean;
runtime: string;
skills: number;
calls: number;
desc: string;
parentId: string | null;
}
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
const cap = summarizeWorkspaceCapabilities(node.data);
const runtime = cap.runtime ?? "unknown";
const remote = runtime === "external";
return {
id: node.id,
name: node.data.name || node.id,
tag: runtime,
tier: tierCode(node.data.tier),
status: normalizeStatus(node.data.status),
remote,
runtime,
skills: cap.skillCount,
calls: typeof node.data.activeTasks === "number" ? node.data.activeTasks : 0,
desc: node.data.role || cap.currentTask || "",
parentId: node.data.parentId ?? null,
};
}
// ── Tab bar ────────────────────────────────────────────────────
export type MobileTabId = "agents" | "canvas" | "comms" | "me";
export function TabBar({
active,
onChange,
dark,
}: {
active: MobileTabId;
onChange: (id: MobileTabId) => void;
dark: boolean;
}) {
const p = usePalette(dark);
const tabs: { id: MobileTabId; label: string; icon: keyof typeof Icons }[] = [
{ id: "agents", label: "Agents", icon: "list" },
{ id: "canvas", label: "Canvas", icon: "graph" },
{ id: "comms", label: "Comms", icon: "pulse" },
{ id: "me", label: "Me", icon: "user" },
];
return (
<div
style={{
position: "absolute",
left: 14,
right: 14,
bottom: 16,
height: 64,
borderRadius: 26,
zIndex: 30,
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.82)",
backdropFilter: "blur(24px) saturate(160%)",
WebkitBackdropFilter: "blur(24px) saturate(160%)",
border: `0.5px solid ${p.border}`,
boxShadow: dark
? "0 8px 28px rgba(0,0,0,0.4), inset 0 0.5px 0 rgba(255,255,255,0.05)"
: "0 6px 20px rgba(40,30,20,0.07), 0 1px 0 rgba(255,255,255,0.6) inset",
display: "flex",
alignItems: "center",
justifyContent: "space-around",
padding: "0 10px",
}}
>
{tabs.map((t) => {
const on = active === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => onChange(t.id)}
style={{
background: "none",
border: "none",
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 3,
padding: "6px 10px",
minWidth: 56,
color: on ? p.accent : p.text3,
}}
>
<span
style={{
width: 36,
height: 28,
borderRadius: 10,
background: on ? `${p.accent}1a` : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{Icons[t.icon]({ size: 18 })}
</span>
<span
style={{
fontSize: 10,
letterSpacing: "0.02em",
fontWeight: on ? 600 : 500,
}}
>
{t.label}
</span>
</button>
);
})}
</div>
);
}
// ── Workspace pill (header) ────────────────────────────────────
export function WorkspacePill({
dark,
count,
live = true,
}: {
dark: boolean;
count: number | string;
live?: boolean;
}) {
const p = usePalette(dark);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 0,
borderRadius: 999,
padding: 4,
background: dark ? "rgba(34,33,28,0.6)" : "rgba(255,255,255,0.7)",
border: `0.5px solid ${p.border}`,
backdropFilter: "blur(12px)",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px 6px 8px",
borderRight: `0.5px solid ${p.divider}`,
}}
>
<span
style={{
width: 22,
height: 22,
borderRadius: 6,
background: `linear-gradient(135deg, ${p.accent}, ${p.greenInk})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 11,
fontWeight: 700,
}}
>
M
</span>
<span style={{ fontSize: 13.5, fontWeight: 600, color: p.text }}>Molecule AI</span>
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 11,
color: p.text2,
}}
>
<StatusDot status="online" size={6} dark={dark} />
<span>{count}</span>
</span>
{live && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "6px 10px 6px 8px",
fontSize: 11,
color: p.greenInk,
fontWeight: 600,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: p.online,
boxShadow: `0 0 0 3px ${p.online}26`,
}}
/>
LIVE
</span>
)}
</div>
);
}
// ── Agent row card ─────────────────────────────────────────────
export function AgentCard({
agent,
dark,
onClick,
compact = false,
}: {
agent: MobileAgent;
dark: boolean;
onClick?: () => void;
compact?: boolean;
}) {
const p = usePalette(dark);
const isOnline = agent.status === "online";
const isT4Soft = agent.tier === "T4" && isOnline;
return (
<button
type="button"
onClick={onClick}
style={{
display: "block",
width: "100%",
textAlign: "left",
cursor: "pointer",
background: isT4Soft ? p.t4SoftCard : isOnline ? p.greenSoft : p.surface,
border: `0.5px solid ${p.border}`,
borderRadius: 18,
padding: compact ? "12px 14px" : "14px 16px",
boxShadow: dark
? "none"
: "0 1px 0 rgba(255,255,255,0.5) inset, 0 1px 2px rgba(40,30,20,0.03)",
transition: "transform .12s",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<StatusDot status={agent.status} size={9} dark={dark} />
<span
style={{
flex: 1,
fontSize: 16,
fontWeight: 600,
color: p.text,
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{agent.name}
</span>
<TierChip tier={agent.tier} dark={dark} />
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 8,
flexWrap: "wrap",
}}
>
{agent.remote && <RemoteBadge palette={p} />}
<span
style={{
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
letterSpacing: "0.02em",
}}
>
{agent.tag}
</span>
</div>
{!compact && agent.desc && (
<p
style={{
margin: "8px 0 0",
fontSize: 13,
lineHeight: 1.45,
color: p.text2,
}}
>
{agent.desc}
</p>
)}
{!compact && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginTop: 10,
fontSize: 10.5,
color: p.text3,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span>SKILLS {agent.skills}</span>
<span>CALLS {agent.calls}</span>
<span style={{ marginLeft: "auto" }}>{agent.runtime.toUpperCase()}</span>
</div>
)}
</button>
);
}
export function RemoteBadge({ palette }: { palette: MobilePalette }) {
return (
<span
style={{
padding: "2px 7px",
borderRadius: 4,
background: palette.remoteBg,
color: palette.remote,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
fontFamily: MOBILE_FONT_MONO,
display: "inline-flex",
alignItems: "center",
gap: 3,
}}
>
REMOTE
</span>
);
}
// ── Filter chips ───────────────────────────────────────────────
export type AgentFilter = "all" | "online" | "issue" | "paused";
export function FilterChips({
value,
onChange,
dark,
counts,
}: {
value: AgentFilter;
onChange: (v: AgentFilter) => void;
dark: boolean;
counts: { all: number; online: number; issue: number; paused: number };
}) {
const p = usePalette(dark);
const opts: { id: AgentFilter; label: string; n: number }[] = [
{ id: "all", label: "All", n: counts.all },
{ id: "online", label: "Online", n: counts.online },
{ id: "issue", label: "Issues", n: counts.issue },
{ id: "paused", label: "Paused", n: counts.paused },
];
return (
<div
style={{
display: "flex",
gap: 6,
padding: "0 16px 10px",
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{opts.map((o) => {
const on = value === o.id;
return (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 999,
cursor: "pointer",
background: on ? p.text : dark ? "#22211c" : "#fff",
color: on ? (dark ? p.bg : "#fff") : p.text,
border: `0.5px solid ${on ? "transparent" : p.border}`,
fontSize: 13,
fontWeight: 500,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{o.label}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
{o.n}
</span>
</button>
);
})}
</div>
);
}
export function classifyForFilter(status: MobileStatus): AgentFilter {
if (status === "online") return "online";
if (status === "failed" || status === "degraded") return "issue";
return "paused"; // starting / paused / offline
}
@@ -1,40 +0,0 @@
"use client";
// React context for accent overrides + the React-side `usePalette` hook.
// Keeps the pure data (MOL_LIGHT/MOL_DARK) in palette.ts and the
// pure-function `getPalette` available for tests; this file is the
// React-only entry point so mobile components don't have to plumb
// accent through props.
import { createContext, useContext, type ReactNode } from "react";
import { MOL_DARK, MOL_LIGHT, type MobilePalette } from "./palette";
const MobileAccentContext = createContext<string | null>(null);
export function MobileAccentProvider({
accent,
children,
}: {
accent: string | null;
children: ReactNode;
}) {
return <MobileAccentContext.Provider value={accent}>{children}</MobileAccentContext.Provider>;
}
/**
* Hook variant of palette resolution. Reads the user's accent override
* from context and returns a fresh palette object with the override
* applied. Critically, it never mutates the static MOL_LIGHT/MOL_DARK
* singletons — that was the foot-gun the prior version had.
*
* Outside of a `<MobileAccentProvider>`, the context default of `null`
* means we just return the static palette unchanged. That's the right
* behaviour for tests + for any non-mobile caller that imports a token.
*/
export function usePalette(dark: boolean): MobilePalette {
const accent = useContext(MobileAccentContext);
const base = dark ? MOL_DARK : MOL_LIGHT;
if (!accent || accent === base.accent) return base;
return { ...base, accent, online: accent };
}
-147
View File
@@ -1,147 +0,0 @@
// Mobile design system tokens — verbatim from the Claude Design handoff
// (molecules-ai-mobile-app/project/shared.jsx). Kept as an inline-style
// palette object so screens can mirror the design 1:1; theming routes
// through `usePalette(dark)` exactly like the prototype.
export interface MobilePalette {
bg: string;
surface: string;
surface2: string;
border: string;
divider: string;
text: string;
text2: string;
text3: string;
green: string;
greenSoft: string;
greenInk: string;
t1Bg: string; t1Ink: string; t1Br: string;
t2Bg: string; t2Ink: string; t2Br: string;
t3Bg: string; t3Ink: string; t3Br: string;
t4Bg: string; t4Ink: string; t4Br: string;
t4SoftCard: string;
online: string;
starting: string;
degraded: string;
failed: string;
paused: string;
offline: string;
remote: string;
remoteBg: string;
accent: string;
}
export const MOL_LIGHT: MobilePalette = {
bg: "#f6f4ef",
surface: "#ffffff",
surface2: "#fbf9f4",
border: "rgba(40,30,20,0.08)",
divider: "rgba(40,30,20,0.06)",
text: "#29261b",
text2: "rgba(41,38,27,0.62)",
text3: "rgba(41,38,27,0.42)",
green: "#2f9e6a",
greenSoft: "#d9ebe0",
greenInk: "#1f6a47",
t1Bg: "#dde6f1", t1Ink: "#3a6aa3", t1Br: "#b9c8de",
t2Bg: "#dbe5f4", t2Ink: "#2f5fb4", t2Br: "#b1c2e0",
t3Bg: "#e3dcef", t3Ink: "#6a4ba1", t3Br: "#c8b9e1",
t4Bg: "#f5dcc7", t4Ink: "#a8501d", t4Br: "#e8c6a4",
t4SoftCard: "#f9ece0",
online: "#2f9e6a",
starting: "#e9b53b",
degraded: "#d28a2a",
failed: "#c8472a",
paused: "#7a8696",
offline: "#9aa0a6",
remote: "#7a4dd1",
remoteBg: "#ede2ff",
accent: "#2f9e6a",
};
export const MOL_DARK: MobilePalette = {
bg: "#15140f",
surface: "#1d1c17",
surface2: "#22211c",
border: "rgba(255,250,240,0.08)",
divider: "rgba(255,250,240,0.06)",
text: "#f1eee5",
text2: "rgba(241,238,229,0.6)",
text3: "rgba(241,238,229,0.38)",
green: "#3eb37c",
greenSoft: "#1f3a2c",
greenInk: "#7fd3a8",
t1Bg: "#1a2230", t1Ink: "#7ea4d4", t1Br: "#2a3a52",
t2Bg: "#1b2434", t2Ink: "#86a6e2", t2Br: "#2c3c58",
t3Bg: "#251f33", t3Ink: "#b39be0", t3Br: "#3e3450",
t4Bg: "#332316", t4Ink: "#e5a878", t4Br: "#553622",
t4SoftCard: "#2a1f17",
online: "#3eb37c",
starting: "#e9b53b",
degraded: "#d28a2a",
failed: "#d65a3e",
paused: "#8a96a6",
offline: "#6a6a6a",
remote: "#a38aff",
remoteBg: "#2a1f44",
accent: "#3eb37c",
};
/**
* Pure-function variant of palette resolution. No React, no context,
* no mutation — for tests and other non-component code.
*
* Components should import `usePalette` from `./palette-context` so the
* user's accent override (held in context, not in module state) flows
* through automatically. Re-exported below so the existing
* `import { usePalette } from "./palette"` call sites keep working.
*/
export const getPalette = (dark: boolean): MobilePalette => (dark ? MOL_DARK : MOL_LIGHT);
// Back-compat re-export. Once we're confident nothing imports
// `usePalette` from this file we can drop this line.
export { usePalette } from "./palette-context";
// References the CSS variables that next/font/google emits in
// app/layout.tsx. Falls through to system fonts if the variable is
// undefined (e.g. in unit tests with no <body> font class).
export const MOBILE_FONT_SANS = "var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif";
export const MOBILE_FONT_MONO = "var(--font-jetbrains), 'JetBrains Mono', ui-monospace, monospace";
// Status keys we surface in the mobile UI. Anything else from the
// platform falls back to "offline" tinting — the desktop has more
// statuses ("provisioning", etc.) than the design's 6-key palette.
export type MobileStatus =
| "online" | "starting" | "degraded" | "failed" | "paused" | "offline";
export function normalizeStatus(s: string | undefined | null): MobileStatus {
if (s === "online" || s === "degraded" || s === "failed" || s === "paused" || s === "offline") {
return s;
}
if (s === "provisioning" || s === "starting") return "starting";
return "offline";
}
// Platform tier (number 1-4) → design tier code "T1".."T4"
export function tierCode(tier: number | undefined | null): "T1" | "T2" | "T3" | "T4" {
const n = typeof tier === "number" ? tier : 2;
if (n <= 1) return "T1";
if (n === 2) return "T2";
if (n === 3) return "T3";
return "T4";
}
-278
View File
@@ -1,278 +0,0 @@
"use client";
// Mobile primitives — StatusDot, TierChip, Chip, Icons, SectionLabel.
// Ports shared.jsx 1:1 from the design handoff; React + TypeScript flavor.
import type { CSSProperties, ReactNode, SVGProps } from "react";
import {
MOBILE_FONT_MONO,
type MobilePalette,
type MobileStatus,
usePalette,
} from "./palette";
type TierCode = "T1" | "T2" | "T3" | "T4";
export function StatusDot({
status = "online",
size = 8,
dark = false,
halo = true,
}: {
status?: MobileStatus;
size?: number;
dark?: boolean;
halo?: boolean;
}) {
const p = usePalette(dark);
const c: string = (p as unknown as Record<string, string>)[status] ?? p.online;
return (
<span
style={{
display: "inline-block",
width: size,
height: size,
borderRadius: 999,
background: c,
flexShrink: 0,
boxShadow: halo ? `0 0 0 ${Math.max(2, size * 0.45)}px ${c}26` : "none",
}}
/>
);
}
export function TierChip({
tier = "T2",
dark = false,
size = "sm",
}: {
tier?: TierCode;
dark?: boolean;
size?: "sm" | "lg";
}) {
const p = usePalette(dark);
const map: Record<TierCode, { bg: string; ink: string; br: string }> = {
T1: { bg: p.t1Bg, ink: p.t1Ink, br: p.t1Br },
T2: { bg: p.t2Bg, ink: p.t2Ink, br: p.t2Br },
T3: { bg: p.t3Bg, ink: p.t3Ink, br: p.t3Br },
T4: { bg: p.t4Bg, ink: p.t4Ink, br: p.t4Br },
};
const { bg, ink, br } = map[tier];
const dim = size === "lg" ? { w: 32, h: 22, fs: 11 } : { w: 26, h: 19, fs: 10 };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: dim.w,
height: dim.h,
borderRadius: 5,
background: bg,
color: ink,
border: `0.5px solid ${br}`,
fontFamily: MOBILE_FONT_MONO,
fontSize: dim.fs,
fontWeight: 600,
letterSpacing: "0.02em",
flexShrink: 0,
}}
>
{tier}
</span>
);
}
export function Chip({
label,
value,
accent,
dark = false,
soft = false,
}: {
label?: string;
value: ReactNode;
accent?: string;
dark?: boolean;
soft?: boolean;
}) {
const p = usePalette(dark);
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 9px",
borderRadius: 999,
background: soft
? `${accent ?? p.accent}1a`
: dark
? "#2a2823"
: "#f0ede5",
border: `0.5px solid ${dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"}`,
fontSize: 11,
fontFamily: MOBILE_FONT_MONO,
color: p.text2,
letterSpacing: "0.02em",
}}
>
{label && (
<span style={{ textTransform: "uppercase", fontSize: 9.5, opacity: 0.7 }}>{label}</span>
)}
<span style={{ color: accent ?? p.text, fontWeight: 600 }}>{value}</span>
</span>
);
}
// ── icons (stroke-based, 20×20 viewBox) ───────────────────────
type IcoOpts = { stroke?: string; size?: number; fill?: string; sw?: number };
const ico = (
paths: ReactNode,
{ stroke = "currentColor", size = 18, fill = "none", sw = 1.6 }: IcoOpts = {},
) => {
const props: SVGProps<SVGSVGElement> = {
width: size,
height: size,
viewBox: "0 0 20 20",
fill,
stroke,
strokeWidth: sw,
strokeLinecap: "round",
strokeLinejoin: "round",
};
return <svg {...props}>{paths}</svg>;
};
export const Icons = {
graph: (o?: IcoOpts) =>
ico(
<>
<circle cx="5" cy="5" r="2" />
<circle cx="15" cy="5" r="2" />
<circle cx="10" cy="15" r="2" />
<path d="M6.4 6.5l2.7 7M13.6 6.5l-2.7 7" />
</>,
o,
),
list: (o?: IcoOpts) =>
ico(
<>
<path d="M6 5h10M6 10h10M6 15h10" />
<circle cx="3.5" cy="5" r="0.6" fill="currentColor" />
<circle cx="3.5" cy="10" r="0.6" fill="currentColor" />
<circle cx="3.5" cy="15" r="0.6" fill="currentColor" />
</>,
o,
),
search: (o?: IcoOpts) =>
ico(
<>
<circle cx="9" cy="9" r="5" />
<path d="M13 13l4 4" />
</>,
o,
),
plus: (o?: IcoOpts) => ico(<path d="M10 4v12M4 10h12" />, o),
bell: (o?: IcoOpts) =>
ico(
<>
<path d="M5 8a5 5 0 0 1 10 0v4l1.5 2H3.5L5 12V8z" />
<path d="M8.5 16a1.5 1.5 0 0 0 3 0" />
</>,
o,
),
chat: (o?: IcoOpts) =>
ico(
<path d="M4 5h12a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 16 14h-3l-3 3v-3H4a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 4 5z" />,
o,
),
send: (o?: IcoOpts) =>
ico(<path d="M3 10l14-6-5 14-3-6-6-2z" fill="currentColor" />, { ...o, sw: 1 }),
attach: (o?: IcoOpts) =>
ico(
<path d="M14 6.5L7.5 13a2.5 2.5 0 0 0 3.5 3.5l7-7a4 4 0 0 0-5.6-5.6L4.8 11A6 6 0 0 0 13.3 19.5" />,
o,
),
back: (o?: IcoOpts) => ico(<path d="M12.5 4l-6 6 6 6" />, o),
more: (o?: IcoOpts) =>
ico(
<>
<circle cx="5" cy="10" r="1.2" fill="currentColor" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" />
<circle cx="15" cy="10" r="1.2" fill="currentColor" />
</>,
o,
),
filter: (o?: IcoOpts) => ico(<path d="M3 5h14M5 10h10M8 15h4" />, o),
user: (o?: IcoOpts) =>
ico(
<>
<circle cx="10" cy="7" r="3" />
<path d="M3.5 17a6.5 6.5 0 0 1 13 0" />
</>,
o,
),
settings: (o?: IcoOpts) =>
ico(
<>
<circle cx="10" cy="10" r="2.2" />
<path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" />
</>,
o,
),
pulse: (o?: IcoOpts) => ico(<path d="M2 10h3l2-5 3 10 2-7 2 4 4-2" />, o),
close: (o?: IcoOpts) => ico(<path d="M5 5l10 10M15 5L5 15" />, o),
zap: (o?: IcoOpts) => ico(<path d="M11 2l-6 9h4l-1 7 6-9h-4l1-7z" />, o),
check: (o?: IcoOpts) => ico(<path d="M4 10l4 4 8-9" />, o),
swatch: (o?: IcoOpts) =>
ico(
<>
<rect x="3" y="3" width="6" height="6" rx="1" />
<rect x="11" y="3" width="6" height="6" rx="1" />
<rect x="3" y="11" width="6" height="6" rx="1" />
<circle cx="14" cy="14" r="3.2" />
</>,
o,
),
};
export function SectionLabel({
children,
dark = false,
right,
style,
}: {
children: ReactNode;
dark?: boolean;
right?: ReactNode;
style?: CSSProperties;
}) {
const p = usePalette(dark);
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 20px 6px",
fontFamily: MOBILE_FONT_MONO,
fontSize: 10.5,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: p.text3,
fontWeight: 600,
...style,
}}
>
<span>{children}</span>
{right}
</div>
);
}
// Convenience: avoid repeating the (palette, dark) plumbing in screens
// that only need the palette object.
export function withPalette<T>(dark: boolean, fn: (p: MobilePalette) => T): T {
return fn(usePalette(dark));
}
+1 -1
View File
@@ -247,7 +247,7 @@ function ActivityRow({
: "bg-surface-card/60 border-line/40"
}`}
>
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2">
{/* Top row: type badge + method + time */}
<div className="flex items-center gap-2">
<span className={`text-[8px] font-mono px-1.5 py-0.5 rounded ${typeStyle.text} ${typeStyle.bg} border ${typeStyle.border}`}>
+1 -1
View File
@@ -370,7 +370,7 @@ export function ChannelsTab({ workspaceId }: Props) {
// Was bg-accent-strong hover:bg-accent — accent is the
// LIGHTER variant; same AA contrast trap fixed in
// ScheduleTab/MemoryTab/OnboardingWizard.
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Connect Channel
</button>
+7 -7
View File
@@ -83,11 +83,11 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
<div className="flex gap-2">
<button type="button" onClick={handleSave} disabled={saving}
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
{saving ? "Saving..." : "Save"}
</button>
<button type="button" onClick={() => setEditing(false)}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
) : (
@@ -101,7 +101,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
)}
{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); }}
className="mt-2 text-[10px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">Edit Agent Card</button>
className="mt-2 text-[10px] text-accent hover:text-accent">Edit Agent Card</button>
</div>
)}
</Section>
@@ -876,7 +876,7 @@ export function ConfigTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => updateNested("runtime_config" as keyof ConfigData, "required_env", currentModelSpec.required_env)}
className="text-accent hover:text-accent underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-accent hover:text-accent underline"
>
Apply
</button>
@@ -1016,7 +1016,7 @@ export function ConfigTab({ workspaceId }: Props) {
onClick={() => handleSave(true)}
disabled={!isDirty || saving}
// Same accent-LIGHTER fix shipped on every other tab.
className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{saving ? "Restarting..." : "Save & Restart"}
</button>
@@ -1024,14 +1024,14 @@ export function ConfigTab({ workspaceId }: Props) {
type="button"
onClick={() => handleSave(false)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors"
>
Save
</button>
<button
type="button"
onClick={loadConfig}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid ml-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid ml-auto"
>
Reload
</button>
+6 -6
View File
@@ -182,7 +182,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
setRole(data.role || "");
setTier(data.tier);
}}
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -211,7 +211,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
type="button"
onClick={handleRestart}
disabled={restarting}
className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50"
>
{restarting ? "Restarting..." : data.status === "failed" ? "Retry" : "Restart"}
</button>
@@ -220,7 +220,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={() => setEditing(true)}
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Edit
</button>
@@ -247,7 +247,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={() => setConsoleOpen(true)}
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid border border-line focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid border border-line"
>
View console output
</button>
@@ -293,7 +293,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
key={p.id}
type="button"
onClick={() => selectNode(p.id)}
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-surface-card text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-surface-card text-left"
>
<StatusDot status={p.status} />
<span className="text-xs text-ink">{p.name}</span>
@@ -353,7 +353,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
type="button"
ref={deleteButtonRef}
onClick={() => setConfirmDelete(true)}
className="px-3 py-1 bg-surface-card hover:bg-red-900 border border-line hover:border-red-700 text-xs rounded text-ink-mid hover:text-bad transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1 bg-surface-card hover:bg-red-900 border border-line hover:border-red-700 text-xs rounded text-ink-mid hover:text-bad transition-colors"
>
Delete Workspace
</button>
+2 -2
View File
@@ -75,7 +75,7 @@ export function EventsTab({ workspaceId }: Props) {
// Was hover:bg-surface-card on top of bg-surface-card — silent
// no-op hover. Lift to surface-elevated, matching the Cancel
// pattern from ConfirmDialog.
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
@@ -106,7 +106,7 @@ export function EventsTab({ workspaceId }: Props) {
// toggles or what it controls.
aria-expanded={isOpen}
aria-controls={panelId}
className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 transition-colors"
className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
>
<span
className={`text-xs font-mono ${
@@ -87,7 +87,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
type="button"
onClick={showConnection}
disabled={busy !== null}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
{busy === "show" ? "Loading…" : "Show connection info"}
</button>
@@ -95,7 +95,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
type="button"
onClick={() => setConfirmRotate(true)}
disabled={busy !== null}
className="px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 border border-red-800/60 text-xs rounded text-bad disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 border border-red-800/60 text-xs rounded text-bad disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600/60"
>
{busy === "rotate" ? "Rotating…" : "Rotate credentials"}
</button>
@@ -124,14 +124,14 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
<button
type="button"
onClick={() => setConfirmRotate(false)}
className="px-3 py-1.5 bg-surface-card text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-surface-card text-xs rounded text-ink-mid"
>
Cancel
</button>
<button
type="button"
onClick={doRotate}
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white"
>
Rotate
</button>
@@ -128,8 +128,8 @@ export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
}}
className={
item.destructive
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 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-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 disabled:opacity-40 disabled:pointer-events-none transition-colors"
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 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>}
@@ -44,7 +44,7 @@ export function FilesToolbar({
<div className="flex gap-1.5">
{root === "/configs" && (
<>
<button type="button" onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" title="Create new file">
<button type="button" onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-accent hover:text-accent" title="Create new file">
+ New
</button>
<input
@@ -57,20 +57,20 @@ export function FilesToolbar({
className="hidden"
onChange={(e) => e.target.files && onUpload(e.target.files)}
/>
<button type="button" onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" title="Upload folder">
<button type="button" onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-accent hover:text-accent" title="Upload folder">
Upload
</button>
</>
)}
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" title="Download all files">
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Download all files">
Export
</button>
{root === "/configs" && (
<button type="button" onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-bad/60 hover:text-bad focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1" title="Delete all files">
<button type="button" onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-bad/60 hover:text-bad" title="Delete all files">
Clear
</button>
)}
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" title="Refresh">
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Refresh">
</button>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
export function getIcon(path: string, isDir: boolean): string {
if (isDir) return "📁";
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
const ext = "." + path.split(".").pop();
return FILE_ICONS[ext] || "📄";
}
+14 -14
View File
@@ -205,14 +205,14 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
>
Open
</button>
@@ -245,7 +245,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
Expand
</button>
@@ -280,21 +280,21 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
+ Add
</button>
@@ -330,7 +330,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white"
>
Save
</button>
@@ -340,7 +340,7 @@ export function MemoryTab({ workspaceId }: Props) {
setShowAdd(false);
setError(null);
}}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -358,7 +358,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="w-full flex items-center justify-between px-3 py-2 text-left"
aria-expanded={expanded === entry.key}
>
<span className="text-xs font-mono text-accent">{entry.key}</span>
@@ -401,14 +401,14 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => handleEditSave(entry)}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white"
>
Save
</button>
<button
type="button"
onClick={cancelEdit}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -428,7 +428,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => beginEdit(entry)}
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
Edit
</button>
@@ -436,7 +436,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
>
Delete
</button>
@@ -459,7 +459,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
Show
</button>
+2 -2
View File
@@ -276,7 +276,7 @@ export function ScheduleTab({ workspaceId }: Props) {
// LIGHTER variant, so this hovered lighter on white text
// and dropped contrast below AA. Same trap fixed in
// OnboardingWizard, ConfirmDialog, ApprovalBanner.
className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{editId ? "Update" : "Create"}
</button>
@@ -285,7 +285,7 @@ export function ScheduleTab({ workspaceId }: Props) {
onClick={resetForm}
// Was hover:bg-surface-card on top of bg-surface-card —
// silent no-op hover. Lift to surface-elevated.
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel
</button>
+1 -1
View File
@@ -479,7 +479,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={() => loadRegistry(true)}
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline"
>
{registryLoading ? "Loading… click to retry" : "Retry"}
</button>
+2 -2
View File
@@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) {
onClick={loadTraces}
// Added focus-visible ring; previous version was hover-only,
// invisible to keyboard users.
className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
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"
>
Refresh
</button>
@@ -98,7 +98,7 @@ export function TracesTab({ workspaceId }: Props) {
// panel. Same pattern shipped on EventsTab.
aria-expanded={isOpen}
aria-controls={panelId}
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 transition-colors"
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
>
{/* Status dot uses semantic bad/good tokens — was hardcoded
bg-red-400 / bg-emerald-400 which doesn't pin to the
@@ -827,14 +827,14 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
type="button"
onClick={handleRestart}
disabled={restarting}
className="px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-800/60 border border-red-700/40 text-[10px] text-red-200 disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-800/60 border border-red-700/40 text-[10px] text-red-200 disabled:opacity-50 transition-colors"
>
{restarting ? "Restarting…" : `Restart ${msg.peerName}`}
</button>
<button
type="button"
onClick={handleOpen}
className="px-2 py-0.5 rounded bg-surface-card hover:bg-surface-card border border-line/50 text-[10px] text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-0.5 rounded bg-surface-card hover:bg-surface-card border border-line/50 text-[10px] text-ink-mid transition-colors"
>
Open {msg.peerName}
</button>
@@ -143,7 +143,7 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
type="button"
onClick={() => setOpen(true)}
title={`Preview ${attachment.name}`}
className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
tone === "user" ? "border-blue-400/30" : "border-line/50"
}`}
aria-label={`Open ${attachment.name} preview`}
@@ -148,7 +148,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
<button
type="button"
onClick={() => onDownload(attachment)}
className="text-ink-mid hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-ink-mid hover:text-ink"
title={`Download ${attachment.name}`}
aria-label={`Download ${attachment.name}`}
>
@@ -162,7 +162,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
<button
type="button"
onClick={() => setExpanded(true)}
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40"
>
Show all {lines.length} lines
</button>
@@ -173,7 +173,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
<button
type="button"
onClick={() => onDownload(attachment)}
className="underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="underline"
>
download full file
</button>
+3 -5
View File
@@ -26,15 +26,13 @@ export function createMessage(
content: string,
attachments?: ChatAttachment[],
): ChatMessage {
return Object.freeze({
return {
id: crypto.randomUUID(),
role,
content,
// Conditional spread avoids `attachments: undefined` appearing in
// Object.keys() when no attachments are provided.
...(attachments?.length ? { attachments } : {}),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
timestamp: new Date().toISOString(),
});
};
}
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
@@ -102,7 +102,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
{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">
{v}
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-mid hover:text-bad focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">×</button>
<button type="button" aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-ink-mid hover:text-bad">×</button>
</span>
))}
</div>
@@ -129,7 +129,7 @@ export function Section({ title, children, defaultOpen = true }: { title: string
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-line rounded mb-2">
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50">
<span className="font-medium uppercase tracking-wider">{title}</span>
<span>{open ? "▾" : "▸"}</span>
</button>
@@ -113,9 +113,9 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
{isSet && <span className="text-[10px] text-good bg-green-900/30 px-1.5 py-0.5 rounded">Set</span>}
{scope && <ScopeBadge scope={scope} />}
{!editing && isSet && (globalMode || scope !== "global") && (
<button type="button" onClick={onDelete} className="text-[11px] text-bad hover:text-bad focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">Remove</button>
<button type="button" onClick={onDelete} className="text-[11px] text-bad hover:text-bad">Remove</button>
)}
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-accent hover:text-accent">
{actionLabel()}
</button>
</div>
@@ -131,7 +131,7 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
<button type="button"
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
disabled={!value}
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30"
>Save</button>
</div>
)}
@@ -165,10 +165,10 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
<span className="text-[10px] text-good">Set</span>
{!globalMode && <ScopeBadge scope={scope} />}
{canDelete && !editing && (
<button type="button" onClick={onDelete} className="text-[11px] text-bad hover:text-bad focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">Remove</button>
<button type="button" onClick={onDelete} className="text-[11px] text-bad hover:text-bad">Remove</button>
)}
{(canDelete || showOverride) && (
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
<button type="button" onClick={() => setEditing(!editing)} className="text-[11px] text-accent hover:text-accent">
{editing ? "Cancel" : showOverride ? "Override" : "Update"}
</button>
)}
@@ -184,7 +184,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
<button type="button"
onClick={() => { onSave(value); setEditing(false); setValue(""); }}
disabled={!value}
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30"
>Save</button>
</div>
)}
@@ -297,7 +297,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
<div className="flex items-center gap-2 pb-1">
<button
onClick={() => setGlobalMode(false)}
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
!globalMode ? "bg-accent-strong/20 text-accent border border-accent/30" : "text-white-soft hover:text-white-mid"
}`}
>
@@ -305,7 +305,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
</button>
<button
onClick={() => setGlobalMode(true)}
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 ${
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
globalMode ? "bg-amber-600/20 text-warm border border-amber-500/30" : "text-white-soft hover:text-white-mid"
}`}
>
@@ -356,15 +356,15 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] text-ink focus:outline-none focus:border-accent" />
<div className="flex gap-2">
<button type="button" onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-30">
Save{globalMode ? " (Global)" : ""}
</button>
<button type="button" onClick={() => { setShowAdd(false); setNewKey(""); setNewValue(""); }}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">Cancel</button>
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
</div>
</div>
) : (
<button type="button" onClick={() => setShowAdd(true)} className="text-[10px] text-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
<button type="button" onClick={() => setShowAdd(true)} className="text-[10px] text-accent hover:text-accent">
+ Add {globalMode ? "Global " : ""}Variable
</button>
)}
@@ -100,14 +100,7 @@ export function toYaml(config: ConfigData): string {
if (!o) return;
lines.push(`${k}:`);
Object.entries(o).forEach(([sk, sv]) => {
if (sv === undefined || sv === null || sv === "") return;
if (Array.isArray(sv)) {
// Nested list block: e.g. required_env: [KEY, SECRET]
lines.push(` ${sk}:`);
sv.forEach((v) => lines.push(` - ${v}`));
} else {
lines.push(` ${sk}: ${sv}`);
}
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
});
};
@@ -128,7 +121,7 @@ export function toYaml(config: ConfigData): string {
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
lines.push(""); list("skills", config.skills);
lines.push(""); list("tools", config.tools);
if (config.tools?.length) { list("tools", config.tools); }
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
+2 -2
View File
@@ -13,14 +13,14 @@ interface RevealToggleProps {
export function RevealToggle({
revealed,
onToggle,
label = 'Toggle reveal secret',
label = 'Toggle visibility',
}: RevealToggleProps) {
return (
<button
type="button"
onClick={onToggle}
aria-label={label}
className="reveal-toggle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="reveal-toggle"
title={revealed ? 'Hide value' : 'Show value'}
>
{revealed ? <EyeOffIcon /> : <EyeIcon />}
@@ -52,10 +52,9 @@ function makeStore(
nodes: Node<WorkspaceNodeData>[] = [],
edges: Edge[] = [],
selectedNodeId: string | null = null,
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
liveAnnouncement = ""
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {}
) {
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
const state = { nodes, edges, selectedNodeId, agentMessages };
const get = () => state;
const set = vi.fn((partial: Record<string, unknown>) => {
Object.assign(state, partial);

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