Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6348522baa | |||
| 97fcb32840 | |||
| 19b95243d2 | |||
| e5622e0dae |
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
staging trigger
|
||||
Generated
+22
-14
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>); }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user