forked from molecule-ai/molecule-core
Compare commits
421 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 314277769e | |||
| e0b567e992 | |||
| 707e4d7342 | |||
| 4f9e3feece | |||
| 10752fe330 | |||
| 8f7122a9b6 | |||
| b3982035b3 | |||
| d1122f8d28 | |||
| 4b35d25d86 | |||
| 46731729d4 | |||
| 6dc2d907a2 | |||
| 849bc97349 | |||
| e13dcab5e0 | |||
| 721010307c | |||
| 9f47ecf86e | |||
| ebc20794f3 | |||
| 73a949bb5c | |||
| 281cb04163 | |||
| fe7ff5440d | |||
| 5b0a75ab73 | |||
| a6dadc7ee0 | |||
| 5e52a0fdad | |||
| 6b445aae2d | |||
| 4f3d51bd61 | |||
| 9a64aeaa2c | |||
| 2d783b5ca6 | |||
| 6fc328ef44 | |||
| bb3212ad37 | |||
| 1986260603 | |||
| d297e75fc9 | |||
| 3ae0513209 | |||
| 4b6373861c | |||
| 3886e8fb9f | |||
| d48693144b | |||
| 1b207b214d | |||
| 1e97fb9a16 | |||
| 7cffff844b | |||
| 4a0d7cd545 | |||
| 35b3ea598a | |||
| 1161b97faf | |||
| 059962a0a3 | |||
| b07575c710 | |||
| 586fa5f84e | |||
| b937415e1e | |||
| 0f46c7eefe | |||
| 8aea1f008c | |||
| 8417bce50d | |||
| 3195657837 | |||
| 7b0bd32957 | |||
| 6fb9bc9bcd | |||
| 9cd2c02f14 | |||
| 9929f73e80 | |||
| 829ab66462 | |||
| 3b3e821a60 | |||
| a08eaa6ca2 | |||
| c5322f318a | |||
| 290e6dfdc3 | |||
| f74fff6ae4 | |||
| 5bfa4b1d80 | |||
| 51e7d94605 | |||
| f2397bf138 | |||
| ff5f4cbf7c | |||
| c53b2b104f | |||
| 01b653d6b0 | |||
| f05633f5b0 | |||
| ff1003e5f6 | |||
| d9fb57092c | |||
| c1cff3169f | |||
| f52de74b7b | |||
| 53d823e719 | |||
| 4511659a9e | |||
| 032c011b37 | |||
| c0997a5703 | |||
| 1d3d18fd66 | |||
| be997883c9 | |||
| 3f4c5f8076 | |||
| e1c99cd24c | |||
| 26b5b21238 | |||
| 25cb17c906 | |||
| 238f4d45df | |||
| bcea8ac822 | |||
| 87ae691e67 | |||
| 99f6481acc | |||
| 2c4bfd83e4 | |||
| 9e8aa39692 | |||
| b7f0b279eb | |||
| fa3353a3ca | |||
| 1187a66d2e | |||
| d360c34a30 | |||
| 287961375f | |||
| 98f883cb99 | |||
| f1840d467c | |||
| 5596cb52ef | |||
| 563e58a835 | |||
| eaee113416 | |||
| 170e037ad1 | |||
| 6f8f978975 | |||
| 034350f823 | |||
| a6b4758f5d | |||
| b4a2c990fb | |||
| ffd90dcf1e | |||
| 44df1befef | |||
| 32fc77bad4 | |||
| ead920ac09 | |||
| 5978cb3c45 | |||
| 3934325e23 | |||
| 2e3e36b91f | |||
| 63d9158e12 | |||
| b7c962bf86 | |||
| 26789988df | |||
| b6ff280ca3 | |||
| acc10ca467 | |||
| f071cbb0a3 | |||
| 3c70ddea5c | |||
| 89e10962b9 | |||
| ff20fe4f61 | |||
| da59b8c5bc | |||
| e307334ca4 | |||
| 0945936eee | |||
| 16ad941a1e | |||
| 25979072fd | |||
| 99738975e2 | |||
| 66de1f1471 | |||
| 0e3e2559af | |||
| 4f6678ae52 | |||
| 5de0eee328 | |||
| 40e35e0b6d | |||
| 7a30af5af0 | |||
| 67e2c9c6b3 | |||
| 43e0f69dc8 | |||
| a6ef5f9583 | |||
| 38b1af3b84 | |||
| 5a50ba86e8 | |||
| 9fea10524e | |||
| 211e375ef1 | |||
| 38e0fc8ea0 | |||
| dd5832a8fc | |||
| 8622829848 | |||
| c5d8ce9ffe | |||
| 90b561add0 | |||
| 81c8a8b35d | |||
| 7ce0138150 | |||
| 408e308ce5 | |||
| 05596803f7 | |||
| 6cd650f48c | |||
| 754e5b2da1 | |||
| f23665d4d9 | |||
| 68c9bd8fe4 | |||
| 4d747de218 | |||
| 4a8a72f4ae | |||
| c4d476d0dc | |||
| 9689c6f6d5 | |||
| 3e4ff1ce9c | |||
| ad24703d74 | |||
| 3e6c7075d0 | |||
| 390425afbc | |||
| 663c5b7e70 | |||
| b70d857409 | |||
| 2f89a05f2f | |||
| d684e28228 | |||
| 71fb499dee | |||
| e5c9656016 | |||
| e5a8ace677 | |||
| d5eb58af56 | |||
| 166c677a09 | |||
| a7f1b378de | |||
| a306a97dd3 | |||
| ec54942628 | |||
| 065e39dda2 | |||
| 54d32d1ee2 | |||
| 4cd01a2df1 | |||
| ccb7ca5d8a | |||
| 10f2b9f01c | |||
| 8760ee1628 | |||
| 28f5108a7c | |||
| e9fdd992a9 | |||
| f6fa3669dc | |||
| b1a1c8e4a9 | |||
| aedbbc4a10 | |||
| 8b9e7e6d59 | |||
| 3c127ae3b9 | |||
| 98da627170 | |||
| 3cd8c53de0 | |||
| 69fcfe9b3a | |||
| 24d64677ab | |||
| 1141a42910 | |||
| 84448d452b | |||
| f689c81a70 | |||
| 2268027581 | |||
| 652124284b | |||
| 79a0203798 | |||
| bae2727074 | |||
| 2c4d92d9bc | |||
| 4c49ff75f6 | |||
| 2e9686036d | |||
| 2bc304bfd3 | |||
| 7ca764f917 | |||
| d2c0041b2b | |||
| 149d0bf3d7 | |||
| c6eec15292 | |||
| 68f8fa2621 | |||
| e4db4cfb11 | |||
| 65b42c33b9 | |||
| 9d45211fd3 | |||
| 14494fe67c | |||
| 3b244ca6c6 | |||
| 18e88e7039 | |||
| f7d663d19a | |||
| c8e422f6c6 | |||
| 1d303ee75e | |||
| 1ec7e4af6d | |||
| ae4739f35b | |||
| 6f203c5646 | |||
| ff0d4dae77 | |||
| 01bbf4c87b | |||
| e89dd892ac | |||
| eba0c5e3f1 | |||
| c3ba5df9ff | |||
| c37596fc26 | |||
| d2c202ddab | |||
| 79590eb861 | |||
| 2d1a86cac9 | |||
| 954d2172f0 | |||
| 9fd52e9cd4 | |||
| ffcffa1375 | |||
| b5dea3c5df | |||
| 54f3c4d34f | |||
| 8d5e78d629 | |||
| ac6f65ab5e | |||
| 5cd5a28bd1 | |||
| 026c81acf0 | |||
| a03045e5e4 | |||
| 61223de305 | |||
| 1355a1b539 | |||
| db132351a3 | |||
| 4e90d3a32d | |||
| e1d635a099 | |||
| 80c6f6e4b6 | |||
| a1e40fe0d9 | |||
| a8708caf73 | |||
| 02ae2fd6fb | |||
| f21d79c4ad | |||
| 120bb1f0a2 | |||
| cfd5ec8d82 | |||
| a4a32cded5 | |||
| 257079c7a2 | |||
| 0567502316 | |||
| 7cba0477cc | |||
| ff3dcd37f6 | |||
| 4e72f1d1db | |||
| e22f7969f8 | |||
| 3d145da99d | |||
| 46c8c1de23 | |||
| 6d38b96043 | |||
| 270a95aa67 | |||
| 6431bdc631 | |||
| 72b6be82b0 | |||
| b42599585e | |||
| 06bfed2e35 | |||
| 80b38900de | |||
| d1eab79d28 | |||
| 824a2a7657 | |||
| 876d6ec8c9 | |||
| 63e3d385d6 | |||
| 2e78812ff9 | |||
| 9664d66e4b | |||
| 19cc83313a | |||
| 097d513b65 | |||
| 2b3f44c3c8 | |||
| c45aa8d7ee | |||
| b4e45374bf | |||
| f2d69f0088 | |||
| bc11ed8a2b | |||
| e2328abedc | |||
| bdad75ae3e | |||
| 90ba2cd4df | |||
| b002247f12 | |||
| 03bcce3eb3 | |||
| c74e71d604 | |||
| d7f88674d8 | |||
| 7abb94dab8 | |||
| effbcd737b | |||
| 6eb79adfd5 | |||
| 8f48a38550 | |||
| 55d85147f7 | |||
| f7e8f98cf7 | |||
| dc6425fe39 | |||
| cbc69f5e7e | |||
| c71f641b12 | |||
| 173e22e091 | |||
| 60a516bc8d | |||
| c0838d637e | |||
| 493ab2566e | |||
| 5e46ea70d6 | |||
| 5cf3dc4369 | |||
| 596e797dca | |||
| 3ce638d6e6 | |||
| df7edfcd3f | |||
| 3ecb25eb4f | |||
| e1628c4d56 | |||
| 78721f7a42 | |||
| 09010212a0 | |||
| bb63e60114 | |||
| 06240ab67b | |||
| 88ef70431e | |||
| 750b32c33f | |||
| 5c3b79a8ba | |||
| e014d22ee9 | |||
| 18c2bdbe68 | |||
| 6f8f7932d2 | |||
| 15124527da | |||
| e9a1ce3591 | |||
| 1bff419833 | |||
| 24276b9458 | |||
| aef9555b1d | |||
| db48d1d261 | |||
| 052575d773 | |||
| c0eca8d0e1 | |||
| e4893f5a9a | |||
| 872f8e8971 | |||
| d58185b8a8 | |||
| 3c0f7de4b9 | |||
| f8b40d8d73 | |||
| 71e7a6ffee | |||
| e2b58f0fbc | |||
| efa68a26b1 | |||
| 87e355c296 | |||
| 67f3e49e42 | |||
| be271aef8b | |||
| 9753d58539 | |||
| 0fc2531250 | |||
| 350495f032 | |||
| 384edb4af0 | |||
| b040171fa1 | |||
| c4f64a11a8 | |||
| 552602e462 | |||
| 29261cee3d | |||
| dfeefb0acc | |||
| 284012a768 | |||
| a28f905c5a | |||
| bdd1d09dfb | |||
| 7f0c58d563 | |||
| 5259ce3ea1 | |||
| 586d567a48 | |||
| 0acd9b3454 | |||
| 992a0c6860 | |||
| 48bc518dfc | |||
| 2eea2b1315 | |||
| bd5f3428d5 | |||
| 8a86b66159 | |||
| 5cc02aa11c | |||
| fd4b4e0723 | |||
| 3e5955f04f | |||
| 16ac895a39 | |||
| 97ebd1910a | |||
| d95877c88d | |||
| 1b75fddb8e | |||
| f33e59ba8c | |||
| a1de71dd53 | |||
| ec63597545 | |||
| 114c941d27 | |||
| 9eb22333a5 | |||
| 993cc4d467 | |||
| fd5fe34f69 | |||
| 0d8b0c37a6 | |||
| 572050f1ed | |||
| 252e126207 | |||
| e84df73e96 | |||
| 7db4129877 | |||
| 9c03b1084f | |||
| 476dbc83a3 | |||
| cf118df2af | |||
| 8dc07b46dd | |||
| 495cc38dd9 | |||
| ecfb160b0d | |||
| dfc1f6d455 | |||
| f61750808e | |||
| 0e0550c640 | |||
| c46db97ac6 | |||
| 1d99b3b8ae | |||
| 6c6c6eb1e8 | |||
| 8072f00b2f | |||
| e1f7d49575 | |||
| ab7ac2e103 | |||
| 3598eb41d1 | |||
| 82d0655fe9 | |||
| ea967d5787 | |||
| 2dd5684e73 | |||
| 2552779d97 | |||
| d88c160e56 | |||
| 5aaac7d2d9 | |||
| 15dd1f26c3 | |||
| 8083fd8b7d | |||
| 1f77f41a80 | |||
| 119518a612 | |||
| 8bf29b7d0e | |||
| fc33cf1131 | |||
| 03c1cbf12b | |||
| 35cb6ba089 | |||
| ce0188d5b4 | |||
| 7224276de0 | |||
| 3d7b4b70ff | |||
| 6e0eb2ddc9 | |||
| 1ce9b7f716 | |||
| 3ce7c11a13 | |||
| bf83af0960 | |||
| 02a8841402 | |||
| b36eed97f6 | |||
| a117a60eed | |||
| cdbf54beed | |||
| fa9e29f2f5 | |||
| 12807962d2 | |||
| 0d25922f91 | |||
| 43c234df35 | |||
| 435e13e57e | |||
| f18ee8598a | |||
| d64570a665 | |||
| 2447c3da11 | |||
| 115f1f5e64 | |||
| 23ee9b5e53 | |||
| 5167e482d0 |
+45
-7
@@ -129,19 +129,57 @@ fi
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# 6. Secrets: No tokens/keys in staged files
|
||||
# ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Pattern set MUST match .github/workflows/secret-scan.yml SECRET_PATTERNS
|
||||
# and molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh —
|
||||
# .github/workflows/secret-pattern-drift.yml lints this invariant. Rebuilt
|
||||
# against canonical 2026-05-02 after #1569 Phase 1 discovery surfaced
|
||||
# real ghs_*/github_pat_* leaks that the prior pattern set
|
||||
# ('sk-ant-|sk-proj-|ghp_|gho_|AKIA|mol_pk_|cfut_') would have missed:
|
||||
# (a) it lacked ghs_ / ghu_ / ghr_ / github_pat_ / sk-svcacct- / sk-cp- /
|
||||
# xox[baprs]- / ASIA prefixes, (b) it skipped *.md and docs/* — but the
|
||||
# actual leaks lived in tick-reflections-temp.md, qa-audit-2026-04-21.md,
|
||||
# docs/incidents/INCIDENT_LOG.md.
|
||||
SECRET_PATTERNS=(
|
||||
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
|
||||
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
|
||||
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
|
||||
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
|
||||
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
|
||||
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
|
||||
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
||||
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
|
||||
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
|
||||
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
|
||||
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens (bot/app/user/refresh)
|
||||
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
|
||||
)
|
||||
|
||||
ALL_STAGED=$(git diff --cached --name-only --diff-filter=ACM || true)
|
||||
if [ -n "$ALL_STAGED" ]; then
|
||||
for f in $ALL_STAGED; do
|
||||
# Skip binary, known safe files, hooks, docs, and markdown
|
||||
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/|\.md$|docs/'; then
|
||||
# Skip ONLY binary + lockfiles + the hook itself. Markdown +
|
||||
# docs/* are NOT skipped — that was the bug (#1569 leaks were
|
||||
# all in *.md). If a doc legitimately needs a token-shaped
|
||||
# placeholder, use ghs_EXAMPLE_TOKEN_DO_NOT_USE — short enough
|
||||
# to dodge the {36,} length suffix.
|
||||
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/'; then
|
||||
continue
|
||||
fi
|
||||
DIFF=$(git diff --cached "$f" 2>/dev/null | grep '^+' | grep -v '^+++' || true)
|
||||
if echo "$DIFF" | grep -qE 'sk-ant-|sk-proj-|ghp_|gho_|AKIA[A-Z0-9]|mol_pk_|cfut_' 2>/dev/null; then
|
||||
echo "❌ POSSIBLE SECRET in $f — do not commit API keys or tokens"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
DIFF=$(git diff --cached --no-color --unified=0 -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
[ -z "$DIFF" ] && continue
|
||||
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||
if echo "$DIFF" | grep -qE "$pattern"; then
|
||||
echo "❌ POSSIBLE SECRET in $f (matched: ${pattern})"
|
||||
echo " The actual matched value is NOT echoed here — round-tripping a"
|
||||
echo " leaked credential into scrollback widens the blast radius."
|
||||
echo " If false positive (test/docs example), use a short placeholder"
|
||||
echo " like ghs_EXAMPLE_TOKEN_DO_NOT_USE that doesn't satisfy the length."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ CONSUMERS: list[tuple[str, str]] = [
|
||||
),
|
||||
]
|
||||
|
||||
# In-repo consumers — paths read locally from the workflow checkout.
|
||||
# Read-from-disk avoids the staging→main lag that the URL fetcher
|
||||
# would hit (a freshly-edited canonical wouldn't yet be on the
|
||||
# consumer's default branch). Same drift semantics, no network.
|
||||
LOCAL_CONSUMERS: list[tuple[str, Path]] = [
|
||||
(
|
||||
".githooks/pre-commit (molecule-core local hook)",
|
||||
Path(".githooks/pre-commit"),
|
||||
),
|
||||
]
|
||||
|
||||
# Matches the SECRET_PATTERNS=( ... ) array in either yaml-indented
|
||||
# (the canonical workflow's `run:` block) or shell-flat (runtime
|
||||
# hook) format. Patterns inside are single-quoted Bash strings; we
|
||||
@@ -89,6 +100,27 @@ def main() -> int:
|
||||
print(f"canonical ({CANONICAL_FILE}): {len(canonical)} patterns")
|
||||
|
||||
drift = False
|
||||
|
||||
# In-repo consumers first — these are read from the workflow's own
|
||||
# checkout, so they never lag behind the canonical and a missing
|
||||
# file IS a real error (not a fetch warning).
|
||||
for label, path in LOCAL_CONSUMERS:
|
||||
if not path.exists():
|
||||
print(f"::error::{label}: file not found at {path}")
|
||||
drift = True
|
||||
continue
|
||||
consumer = extract_patterns(path.read_text(), label)
|
||||
missing, extra = diff_patterns(canonical, consumer)
|
||||
if not missing and not extra:
|
||||
print(f" ✓ {label}: aligned ({len(consumer)} patterns)")
|
||||
continue
|
||||
drift = True
|
||||
print(f"::error::DRIFT in {label}:")
|
||||
for p in missing:
|
||||
print(f" - missing from consumer: {p!r}")
|
||||
for p in extra:
|
||||
print(f" - extra in consumer (not in canonical): {p!r}")
|
||||
|
||||
for label, url in CONSUMERS:
|
||||
try:
|
||||
content = fetch(url)
|
||||
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- if: steps.gate.outputs.proceed == 'true'
|
||||
uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
|
||||
- name: GHCR login
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
|
||||
@@ -111,7 +111,60 @@ jobs:
|
||||
all_green: ${{ steps.gates.outputs.all_green }}
|
||||
head_sha: ${{ steps.gates.outputs.head_sha }}
|
||||
steps:
|
||||
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync cycle
|
||||
# observed 2026-05-03). Sequence: auto-promote merges via the staging
|
||||
# merge-queue's MERGE strategy, creating a merge commit on main that
|
||||
# staging doesn't have. auto-sync then merges main back into staging
|
||||
# via another merge commit (the queue's MERGE strategy applies on
|
||||
# the staging side too, even when the workflow's local FF would
|
||||
# have sufficed). Now staging has a new merge-commit SHA whose
|
||||
# tree == main's tree — but auto-promote sees "staging ahead of
|
||||
# main by 1" and opens YET another empty promote PR. Each round
|
||||
# costs ~30-40 min wallclock, ~2 manual approvals, and burns a
|
||||
# full CodeQL Go run (~15 min). Without this guard the cycle
|
||||
# repeats indefinitely.
|
||||
#
|
||||
# Long-term fix is to switch the merge_queue ruleset's
|
||||
# `merge_method` away from MERGE so FF-able PRs land cleanly,
|
||||
# but that's a broader change affecting every staging PR's
|
||||
# commit shape. This guard is the one-line surgical fix that
|
||||
# breaks the cycle without touching merge-queue config.
|
||||
#
|
||||
# Fail-open: if `git diff` errors for any reason, fall through
|
||||
# to the gate check (preserve existing behavior). Only skip
|
||||
# when the diff is DEFINITIVELY empty.
|
||||
- name: Checkout for tree-diff check
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
- name: Skip if staging tree == main tree (perpetual-cycle break)
|
||||
id: tree-diff
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch origin main --depth=50 || { echo "::warning::git fetch main failed — proceeding (fail-open)"; exit 0; }
|
||||
# Compare staging tip's tree against main's tree. `git diff
|
||||
# --quiet` exits 0 if no differences, 1 if there are.
|
||||
if git diff --quiet origin/main "$HEAD_SHA" -- 2>/dev/null; then
|
||||
{
|
||||
echo "## ⏭ Skipped — no code to promote"
|
||||
echo
|
||||
echo "staging tip (\`${HEAD_SHA:0:8}\`) and \`main\` have identical trees."
|
||||
echo "This is the auto-promote↔auto-sync merge-commit cycle: staging has a"
|
||||
echo "new SHA (a sync-back merge commit) but the underlying file tree is"
|
||||
echo "already on main, so there's no real code to ship."
|
||||
echo
|
||||
echo "Skipping to avoid opening an empty promote PR. Cycle terminates here."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote: staging tree == main tree — no code to promote, skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check all required gates on this SHA
|
||||
if: steps.tree-diff.outputs.skip != 'true'
|
||||
id: gates
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -209,10 +262,25 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mint the App token BEFORE the promote-PR step so the auto-merge
|
||||
# call can use it. GITHUB_TOKEN-initiated merges suppress the
|
||||
# downstream `push` event on main, breaking the
|
||||
# publish-workspace-server-image → canary-verify → redeploy-tenants
|
||||
# chain (issue #2357). Using the App token here means the
|
||||
# merge-queue-landed merge IS able to fire the cascade naturally;
|
||||
# the polling tail below stays as defense-in-depth.
|
||||
- name: Mint App token for promote-PR + downstream dispatch
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
|
||||
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open (or reuse) staging → main promote PR + enable auto-merge
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
|
||||
run: |
|
||||
@@ -267,52 +335,34 @@ jobs:
|
||||
echo "promote_pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT"
|
||||
id: promote_pr
|
||||
|
||||
# Mint a short-lived GitHub App installation token for the dispatch
|
||||
# step below. We CANNOT use `secrets.GITHUB_TOKEN` to dispatch the
|
||||
# downstream publish chain — workflow runs created by GITHUB_TOKEN
|
||||
# do not fire `workflow_run` triggers on completion (the
|
||||
# documented "no recursion" rule —
|
||||
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
#
|
||||
# Symptom this caused (root-caused on 2026-04-30): publish-image
|
||||
# ran successfully twice (21313dc 14:41Z, 59dec57 15:21Z) but
|
||||
# canary-verify and redeploy-tenants-on-main never chained,
|
||||
# because the publish run's `triggering_actor` was
|
||||
# `github-actions[bot]` (i.e. GITHUB_TOKEN). A manual dispatch
|
||||
# earlier in the day with the operator's PAT (d850ec7 06:52Z) did
|
||||
# chain — same workflow file, only the actor differed.
|
||||
#
|
||||
# An App token's triggering_actor is the App user (e.g.
|
||||
# `molecule-ai[bot]`), which IS allowed to fire downstream
|
||||
# workflow_run cascades.
|
||||
- name: Mint App token for downstream dispatch
|
||||
if: steps.promote_pr.outputs.promote_pr_num != ''
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
|
||||
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
|
||||
|
||||
# The App token minted above (before the promote-PR step) is
|
||||
# also used by the polling tail below. Defense-in-depth: with
|
||||
# the merge-queue-landed merge now using the App token, the
|
||||
# main-branch push event SHOULD fire the publish/canary/redeploy
|
||||
# cascade naturally — but if for any reason it doesn't (e.g. an
|
||||
# unrelated event-suppression edge case), the explicit dispatches
|
||||
# below still wake the chain.
|
||||
- name: Wait for promote merge, then dispatch publish + redeploy (#2357)
|
||||
# GITHUB_TOKEN-initiated merges suppress downstream `push` events
|
||||
# (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
# Result: when the merge queue lands the promote PR, the resulting
|
||||
# main-branch push DOES NOT fire publish-workspace-server-image,
|
||||
# so canary-verify and redeploy-tenants-on-main never run and
|
||||
# tenants stay on stale code (issue #2357).
|
||||
# Defense-in-depth dispatch. With the auto-merge call above
|
||||
# now using the App token (this commit), the merge-queue-landed
|
||||
# merge SHOULD fire publish-workspace-server-image naturally
|
||||
# via on:push:[main] — App-token-initiated pushes DO trigger
|
||||
# workflow_run cascades, unlike GITHUB_TOKEN-initiated ones
|
||||
# (the documented "no recursion" rule —
|
||||
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
#
|
||||
# Workaround: poll for the merge to land, then explicitly
|
||||
# `gh workflow run` publish-workspace-server-image. The dispatch
|
||||
# MUST authenticate as the molecule-ai App (App token minted
|
||||
# above) — not GITHUB_TOKEN — so that the resulting publish
|
||||
# run's completion event can fire the workflow_run cascade
|
||||
# into canary-verify + redeploy-tenants-on-main. See the prior
|
||||
# step's comment for the GITHUB_TOKEN no-recursion details.
|
||||
# This explicit dispatch stays as belt-and-suspenders for any
|
||||
# edge case where the natural cascade misfires. If it never
|
||||
# observably fires after this token swap (i.e. the publish
|
||||
# workflow has already started by the time we get here), the
|
||||
# second dispatch is a harmless no-op (publish-workspace-server-image
|
||||
# has its own concurrency group that dedupes).
|
||||
#
|
||||
# Long-term fix: switch the auto-merge call above to use the
|
||||
# same App token, so the merge's push event fires
|
||||
# publish-workspace-server-image naturally and this polling tail
|
||||
# becomes unnecessary. Tracked in #2357.
|
||||
# See PR for #2357: pre-fix the merge action was via
|
||||
# GITHUB_TOKEN, suppressing the cascade and forcing this tail
|
||||
# to be the SOLE chain trigger. With the auto-merge token swap
|
||||
# the tail becomes redundant in the happy path; keep until
|
||||
# we've observed >=10 successful natural cascades, then drop.
|
||||
if: steps.promote_pr.outputs.promote_pr_num != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout staging
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need full tag history for `git describe` / sort
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Block forbidden paths
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2 # need previous commit to diff against on push events
|
||||
|
||||
|
||||
@@ -50,23 +50,39 @@ jobs:
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
# Without an LLM key the test_staging_full_saas.sh script provisions
|
||||
# the workspace with empty secrets, hermes derive-provider.sh resolves
|
||||
# `openai/gpt-4o` to PROVIDER=openrouter, no OPENROUTER_API_KEY is
|
||||
# found in env, and A2A returns "No LLM provider configured" at
|
||||
# request time (canary step 8/11). The full-lifecycle workflow
|
||||
# (e2e-staging-saas.yml) has carried this secret since launch — the
|
||||
# canary regressed when it was first split out and lost the env
|
||||
# block. Issue #1500 had ~30 consecutive failures before this was
|
||||
# spotted; do NOT remove without re-reading the script's secrets-
|
||||
# injection block.
|
||||
# MiniMax is the canary's PRIMARY LLM auth path post-2026-05-04.
|
||||
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
# the canary red the entire time). claude-code template's
|
||||
# `minimax` provider routes ANTHROPIC_BASE_URL to
|
||||
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot —
|
||||
# ~5-10x cheaper per token than gpt-4.1-mini AND on a separate
|
||||
# billing account, so OpenAI quota collapse no longer wedges the
|
||||
# canary. Mirrors the migration continuous-synth-e2e.yml made on
|
||||
# 2026-05-03 (#265) for the same reason. tests/e2e/test_staging_
|
||||
# full_saas.sh branches SECRETS_JSON on which key is present —
|
||||
# MiniMax wins when set.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so an operator-dispatched run with
|
||||
# E2E_RUNTIME=hermes overridden via workflow_dispatch can still
|
||||
# exercise the OpenAI path without re-editing the workflow.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
E2E_MODE: canary
|
||||
E2E_RUNTIME: hermes
|
||||
E2E_RUNTIME: claude-code
|
||||
# Pin the canary to a specific MiniMax model rather than relying
|
||||
# on the per-runtime default (which could resolve to "sonnet" →
|
||||
# direct Anthropic and defeat the cost saving). M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
||||
E2E_RUN_ID: "canary-${{ github.run_id }}"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
@@ -75,13 +91,47 @@ jobs:
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Verify OpenAI key present
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
if [ -z "$E2E_OPENAI_API_KEY" ]; then
|
||||
echo "::error::MOLECULE_STAGING_OPENAI_KEY secret not set — A2A will fail at request time with 'No LLM provider configured'"
|
||||
# Per-runtime key check — claude-code uses MiniMax; hermes /
|
||||
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# rather than soft-skip per the lesson from synth E2E #2578:
|
||||
# an empty key silently falls through to the wrong
|
||||
# SECRETS_JSON branch and the canary fails 5 min later with
|
||||
# a confusing auth error instead of the clean "secret
|
||||
# missing" message at the top.
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
# Either MiniMax OR direct-Anthropic works — first
|
||||
# non-empty wins in the test script's secrets-injection
|
||||
# priority chain. Operators only need to set ONE of these
|
||||
# secrets; we don't force a choice between them.
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — A2A will fail at request time with 'No LLM provider configured'"
|
||||
exit 2
|
||||
fi
|
||||
echo "OpenAI key present ✓ (len=${#E2E_OPENAI_API_KEY})"
|
||||
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
|
||||
|
||||
- name: Canary run
|
||||
id: canary
|
||||
@@ -98,7 +148,7 @@ jobs:
|
||||
# next deploy window.
|
||||
- name: Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
# Inject the workflow path explicitly — context.workflow is
|
||||
# the *name*, not the file path the actions API needs.
|
||||
@@ -165,7 +215,7 @@ jobs:
|
||||
|
||||
- name: Auto-close canary issue on success
|
||||
if: success()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = '🔴 Canary failing: staging SaaS smoke';
|
||||
@@ -231,10 +281,34 @@ jobs:
|
||||
and o.get('status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug DELETE with HTTP-code verification. The previous
|
||||
# `... >/dev/null || true` swallowed every failure, so a 5xx
|
||||
# or timeout from CP looked identical to "successfully cleaned
|
||||
# up" and the tenant kept eating ~2 vCPU until the hourly
|
||||
# stale sweep caught it (up to 2h later). Now we capture the
|
||||
# response code and surface non-2xx as a workflow warning, so
|
||||
# the run page shows which slug leaked. We still don't `exit 1`
|
||||
# on cleanup failure — a single-canary cleanup miss shouldn't
|
||||
# fail-flag the canary itself when the actual smoke check
|
||||
# passed. The sweep-stale-e2e-orgs cron (now every 15 min,
|
||||
# 30-min threshold) is the safety net for whatever slips past.
|
||||
# See molecule-controlplane#420.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/canary-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::canary teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canary-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::canary teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
smoke_ran: ${{ steps.smoke.outputs.ran }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Compute sha
|
||||
id: compute
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
|
||||
- name: GHCR login
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: cascade-list-drift-gate
|
||||
|
||||
# Structural gate: TEMPLATES list in publish-runtime.yml must match
|
||||
# manifest.json's workspace_templates exactly. Closes the recurrence
|
||||
# path of PR #2556 (the data fix) and is the first concrete deliverable
|
||||
# of RFC #388 PR-3.
|
||||
#
|
||||
# Why a gate, not just discipline: PR #2536 pruned the manifest, but the
|
||||
# cascade list wasn't updated for ~weeks before someone (PR #2556)
|
||||
# noticed during an unrelated audit. During that window, codex never
|
||||
# rebuilt on a runtime publish. A structural gate catches the drift
|
||||
# the same day either file changes.
|
||||
#
|
||||
# Triggers narrowly to keep CI quiet: only on PRs that actually change
|
||||
# one of the two files. The path-filtered split + always-emit-result
|
||||
# pattern (memory: "Required check names need a job that always runs")
|
||||
# is unnecessary here because the workflow IS the check name and PR
|
||||
# branch protection should require it directly. Future-proof: if this
|
||||
# becomes a required check, add a no-op aggregator with always() so the
|
||||
# name still emits when paths don't match.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- manifest.json
|
||||
- .github/workflows/publish-runtime.yml
|
||||
- scripts/check-cascade-list-vs-manifest.sh
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Check cascade list matches manifest
|
||||
run: bash scripts/check-cascade-list-vs-manifest.sh
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Verify merge_group trigger on required-check workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Need history to diff against base ref
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
scripts: ${{ steps.check.outputs.scripts }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
working-directory: .
|
||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
||||
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
||||
@@ -272,6 +272,18 @@ jobs:
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run E2E bash unit tests (no live infra)
|
||||
# Pure-bash unit tests for E2E helper libs (lib/*.sh). These pin
|
||||
# behavior of dispatch logic that — when broken — silently masks as
|
||||
# "Could not resolve authentication method" only after a successful
|
||||
# tenant + workspace provision (PR #2571 incident, 2026-05-03). Add
|
||||
# new self-contained unit tests here as the lib/ directory grows;
|
||||
# tests requiring live CP/tenant credentials belong in the dedicated
|
||||
# e2e-staging-* workflows, not this job.
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
@@ -332,7 +344,7 @@ jobs:
|
||||
working-directory: .
|
||||
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -53,14 +53,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Same reasoning as publish-workspace-server-image.yml — the Go
|
||||
# module's replace directive needs the plugin source so
|
||||
# CodeQL's "go build" phase can resolve.
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
|
||||
@@ -32,16 +32,41 @@ name: Continuous synthetic E2E (staging)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 20 minutes, on the :00 :20 :40. Offsets the existing :15
|
||||
# sweep-cf-orphans and :45 sweep-cf-tunnels so the three
|
||||
# operations don't all hit Cloudflare/AWS at the same minute.
|
||||
- cron: '0,20,40 * * * *'
|
||||
# Every 10 minutes, on :02 :12 :22 :32 :42 :52. Three constraints:
|
||||
# 1. Stay off the top-of-hour. GitHub Actions scheduler drops
|
||||
# :00 firings under high load (own docs:
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule).
|
||||
# Prior history: cron was '0,20,40' (2026-05-02) — only :00
|
||||
# ever survived. Bumped to '10,30,50' (2026-05-03) on the
|
||||
# theory that further-from-:00 wins. Empirically 2026-05-04
|
||||
# that ALSO dropped to ~60 min effective cadence (only ~1
|
||||
# schedule fire per hour — see molecule-core#2726). Detection
|
||||
# latency was claimed 20 min, actual 60 min.
|
||||
# 2. Avoid colliding with the existing :15 sweep-cf-orphans
|
||||
# and :45 sweep-cf-tunnels — both hit the CF API and we
|
||||
# don't want to fight for rate-limit tokens.
|
||||
# 3. Avoid the :30 heavy slot (canary-staging /30, sweep-aws-
|
||||
# secrets, sweep-stale-e2e-orgs every :15) — multiple
|
||||
# overlapping cron registrations on the same minute is part
|
||||
# of what GH drops under load.
|
||||
# Solution: bump fires-per-hour 3 → 6 AND keep all slots in clean
|
||||
# lanes (1-3 min away from any other cron). Even with empirically-
|
||||
# observed ~67% GH drop ratio, 6 attempts/hour yields ~2 effective
|
||||
# fires = ~30 min cadence; closer to the 20-min target than the
|
||||
# current shape and provides a real degradation alarm if drops
|
||||
# get worse.
|
||||
- cron: '2,12,22,32,42,52 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runtime:
|
||||
description: "Runtime to provision (langgraph = fastest, default; hermes = slower but covers SDK-native path; claude-code = needs OAUTH token in tenant env)"
|
||||
description: "Runtime to provision (claude-code = default + cheapest via MiniMax; langgraph = OpenAI-only; hermes = SDK-native path, slower)"
|
||||
required: false
|
||||
default: "langgraph"
|
||||
default: "claude-code"
|
||||
type: string
|
||||
model_slug:
|
||||
description: "Model id to provision the workspace with (default MiniMax-M2.7-highspeed; e.g. 'sonnet' to test direct Anthropic, 'openai/gpt-4o' for hermes)"
|
||||
required: false
|
||||
default: "MiniMax-M2.7-highspeed"
|
||||
type: string
|
||||
keep_org:
|
||||
description: "Skip teardown for post-mortem debugging (only manual dispatch — never set this for cron runs)"
|
||||
@@ -68,15 +93,36 @@ jobs:
|
||||
synth:
|
||||
name: Synthetic E2E against staging
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
# Bumped from 12 → 20 (2026-05-04). Tenant user-data install phase
|
||||
# (apt-get update + install docker.io/jq/awscli/caddy + snap install
|
||||
# ssm-agent) runs from raw Ubuntu on every boot — none of it is
|
||||
# pre-baked into the tenant AMI. Empirical fetch_secrets/ok timing
|
||||
# across today's canaries: 51s → 82s → 143s → 625s. apt-mirror tail
|
||||
# latency drives the boot-to-fetch_secrets phase from ~1min to >10min.
|
||||
# A 12min budget leaves only ~2min for the workspace (which needs
|
||||
# ~3.5min for claude-code cold boot) on slow-apt days, blowing the
|
||||
# budget. 20min absorbs the worst tenant tail so the workspace probe
|
||||
# gets the full ~7min it needs even on a slow apt day. Real fix:
|
||||
# pre-bake caddy + ssm-agent into the tenant AMI (controlplane#TBD).
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# langgraph default keeps cold-start under 5 min on staging EC2.
|
||||
# hermes is slower (~7-10 min) and isn't needed for the
|
||||
# regression class this gate exists to catch (deployment-pipeline
|
||||
# + schema-drift + integration). Operators can pick hermes via
|
||||
# workflow_dispatch when they need to exercise the SDK-native
|
||||
# session path.
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'langgraph' }}
|
||||
# claude-code default: cold-start ~5 min (comparable to langgraph),
|
||||
# but uses MiniMax-M2.7-highspeed via the template's third-party-
|
||||
# Anthropic-compat path (workspace-configs-templates/claude-code-
|
||||
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
|
||||
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
|
||||
# exhaustion class that took the canary down 2026-05-03 (#265).
|
||||
# Operators can pick langgraph / hermes via workflow_dispatch
|
||||
# when they specifically need to exercise the OpenAI or SDK-
|
||||
# native paths.
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
|
||||
# Pin the canary to a specific MiniMax model rather than relying
|
||||
# on the per-runtime default ("sonnet" → routes to direct
|
||||
# Anthropic, defeats the cost saving). Operators can override
|
||||
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
|
||||
# input if they need to exercise a specific model. M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
|
||||
# Bound to 10 min so a stuck provision fails the run instead of
|
||||
# holding up the next cron firing. 15-min default in the script
|
||||
# is for the on-PR full lifecycle where we have more headroom.
|
||||
@@ -88,28 +134,79 @@ jobs:
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
|
||||
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
# MiniMax key is the canary's PRIMARY auth path. claude-code
|
||||
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
|
||||
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
|
||||
# tests/e2e/test_staging_full_saas.sh branches SECRETS_JSON on
|
||||
# which key is present — MiniMax wins when set.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so operators can dispatch with
|
||||
# E2E_RUNTIME=langgraph or =hermes and still have a working
|
||||
# canary path. The script picks the right blob shape based on
|
||||
# which key is non-empty.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secret present
|
||||
- name: Verify required secrets present
|
||||
run: |
|
||||
# Schedule-vs-dispatch hardening (mirrors the sweep-cf-* and
|
||||
# redeploy-tenants-on-* workflows): hard-fail on missing secret
|
||||
# for cron firing so a misconfigured-repo doesn't silently
|
||||
# report green while doing nothing. Soft-skip on operator
|
||||
# dispatch — operators can dispatch ad-hoc to verify a fix
|
||||
# without setting up the secret first.
|
||||
# Hard-fail on missing secret REGARDLESS of trigger. Previously
|
||||
# this step soft-skipped on workflow_dispatch via `exit 0`, but
|
||||
# `exit 0` only ends the STEP — subsequent steps still ran with
|
||||
# the empty secret, the synth script fell through to the wrong
|
||||
# SECRETS_JSON branch, and the canary failed 5 min later with a
|
||||
# confusing "Agent error (Exception)" instead of the clean
|
||||
# "secret missing" message at the top. Caught 2026-05-04 by
|
||||
# dispatched run 25296530706: claude-code + missing MINIMAX
|
||||
# silently used OpenAI keys but kept model=MiniMax-M2.7, then
|
||||
# the workspace 401'd against MiniMax once it tried to call.
|
||||
# Fix: exit 1 in both cron and dispatch paths. Operators who
|
||||
# want to verify a YAML change without setting up the secret
|
||||
# can read the verify-secrets step's stderr — the failure is
|
||||
# itself the verification signal.
|
||||
if [ -z "${MOLECULE_ADMIN_TOKEN:-}" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::CP_STAGING_ADMIN_API_TOKEN not set — synth E2E cannot run"
|
||||
echo "::warning::Set it at Settings → Secrets and Variables → Actions"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret missing — synth E2E cannot run"
|
||||
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# LLM-key requirement is per-runtime: claude-code accepts
|
||||
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
|
||||
# langgraph + hermes use OpenAI (MOLECULE_STAGING_OPENAI_KEY).
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret missing — runtime=${E2E_RUNTIME} cannot authenticate against its LLM provider"
|
||||
echo "::error::Set it at Settings → Secrets and Variables → Actions, OR dispatch with a different runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install required tools
|
||||
run: |
|
||||
# The script depends on jq + curl (already on ubuntu-latest)
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
outputs:
|
||||
api: ${{ steps.decide.outputs.api }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
echo "No workspace-server / tests/e2e / workflow changes — E2E API gate satisfied without running tests."
|
||||
echo "::notice::E2E API Smoke Test no-op pass (paths filter excluded this commit)."
|
||||
- if: needs.detect-changes.outputs.api == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.detect-changes.outputs.api == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
outputs:
|
||||
canvas: ${{ steps.decide.outputs.canvas }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
echo "::notice::E2E Staging Canvas no-op pass (paths filter excluded this commit)."
|
||||
|
||||
- if: needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
@@ -184,8 +184,23 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
echo "Deleting orphan tenant: $slug"
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
# Verify HTTP 2xx instead of `>/dev/null || true` swallowing
|
||||
# failures. A 5xx or timeout previously looked identical to
|
||||
# success, leaving the tenant alive for up to ~45 min until
|
||||
# sweep-stale-e2e-orgs caught it. Surface failures as
|
||||
# workflow warnings naming the slug. Don't `exit 1` — a single
|
||||
# cleanup miss shouldn't fail-flag the canvas test when the
|
||||
# actual smoke check passed; the sweeper is the safety net.
|
||||
# See molecule-controlplane#420.
|
||||
code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
E2E_STALE_WAIT_SECS: ${{ github.event.inputs.stale_wait_secs || '180' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
@@ -153,12 +153,28 @@ jobs:
|
||||
if [ -n "$orgs" ]; then
|
||||
echo "Safety-net sweep: deleting leftover orgs:"
|
||||
echo "$orgs"
|
||||
# Per-slug verified DELETE — see molecule-controlplane#420.
|
||||
# `>/dev/null 2>&1` previously hid every failure; surface
|
||||
# non-2xx as workflow warnings so the run page names what
|
||||
# leaked. Sweeper catches the rest within ~45 min.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null 2>&1
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::external teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/external-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::external teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
else
|
||||
echo "Safety-net sweep: no leftover orgs to clean."
|
||||
fi
|
||||
|
||||
@@ -48,9 +48,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runtime:
|
||||
description: "Runtime to test (hermes | claude-code | langgraph)"
|
||||
description: "Runtime to test (claude-code [default, MiniMax] | hermes [OpenAI] | langgraph [OpenAI])"
|
||||
required: false
|
||||
default: "hermes"
|
||||
default: "claude-code"
|
||||
keep_org:
|
||||
description: "Skip teardown for debugging (only use via manual dispatch!)"
|
||||
required: false
|
||||
@@ -83,16 +83,37 @@ jobs:
|
||||
# retrieval + teardown. Configure in
|
||||
# Settings → Secrets and variables → Actions → Repository secrets.
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
# OpenAI key for workspace LLM calls (section 8 A2A). Without it,
|
||||
# Hermes runtime crashes at boot with "No provider API key found".
|
||||
# Configure at Settings → Secrets → Actions → MOLECULE_STAGING_OPENAI_KEY.
|
||||
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
|
||||
# from hermes+OpenAI default after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
# the full-lifecycle E2E red on every provisioning-critical push).
|
||||
# claude-code template's `minimax` provider routes
|
||||
# ANTHROPIC_BASE_URL to api.minimax.io/anthropic and reads
|
||||
# MINIMAX_API_KEY at boot — separate billing account so an
|
||||
# OpenAI quota collapse no longer wedges the gate. Mirrors the
|
||||
# canary-staging.yml + continuous-synth-e2e.yml migrations.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so an operator-dispatched run with
|
||||
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
|
||||
# exercise the OpenAI path.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'hermes' }}
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
|
||||
# Pin the model when running on the default claude-code path —
|
||||
# the per-runtime default ("sonnet") routes to direct Anthropic
|
||||
# and defeats the cost saving. Operators can override via the
|
||||
# workflow_dispatch flow (no input wired here yet — runtime
|
||||
# override is enough for ad-hoc).
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
@@ -102,13 +123,45 @@ jobs:
|
||||
fi
|
||||
echo "Admin token present ✓"
|
||||
|
||||
- name: Verify OpenAI key present
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
if [ -z "$E2E_OPENAI_API_KEY" ]; then
|
||||
echo "::error::MOLECULE_STAGING_OPENAI_KEY secret not set — workspaces will fail at boot with 'No provider API key found'"
|
||||
# Per-runtime key check — claude-code uses MiniMax; hermes /
|
||||
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# rather than soft-skip per #2578's lesson — empty key
|
||||
# silently falls through to the wrong SECRETS_JSON branch and
|
||||
# produces a confusing auth error 5 min later instead of the
|
||||
# clean "secret missing" message at the top.
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
# Either MiniMax OR direct-Anthropic works — first
|
||||
# non-empty wins in the test script's secrets-injection
|
||||
# priority chain.
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — workspaces will fail at boot with 'No provider API key found'"
|
||||
exit 2
|
||||
fi
|
||||
echo "OpenAI key present ✓ (len=${#E2E_OPENAI_API_KEY})"
|
||||
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
@@ -164,11 +217,27 @@ jobs:
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug verified DELETE (was `>/dev/null || true` — see
|
||||
# molecule-controlplane#420). Surface non-2xx as a workflow
|
||||
# warning naming the leaked slug; don't exit 1 (sweeper is
|
||||
# the safety net within ~45 min).
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::saas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/saas-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::saas teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
E2E_INTENTIONAL_FAILURE: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Open issue if safety net is broken
|
||||
if: failure()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = "🚨 E2E teardown safety net broken";
|
||||
@@ -143,10 +143,25 @@ jobs:
|
||||
and o.get('status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug verified DELETE — see molecule-controlplane#420.
|
||||
# Failures surface as workflow warnings; the sweeper is the
|
||||
# safety net within ~45 min.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::sanity teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/sanity-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::sanity teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
outputs:
|
||||
run: ${{ steps.decide.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
@@ -93,14 +93,14 @@ jobs:
|
||||
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
|
||||
|
||||
- if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Dockerfile.tenant copies molecule-ai-plugin-github-app-auth/
|
||||
# at the build-context root (see workspace-server/Dockerfile.tenant
|
||||
# line 19). PLUGIN_REPO_PAT pattern matches publish-workspace-server-image.yml.
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
|
||||
- name: GHCR login
|
||||
run: |
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
echo "ws_url=${WS_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build & push canvas image to GHCR
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ./canvas
|
||||
file: ./canvas/Dockerfile
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
@@ -327,7 +327,19 @@ jobs:
|
||||
echo "::error::publish job did not expose a version output — cascade cannot fan out"
|
||||
exit 1
|
||||
fi
|
||||
TEMPLATES="claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw"
|
||||
# All 9 active workspace template repos. The PR #2536 pruning
|
||||
# ("deprecated, no shipping images") was empirically wrong:
|
||||
# continuous-synth-e2e.yml defaults to langgraph as its primary
|
||||
# canary (line 44), and every excluded template had successful
|
||||
# publish-image runs as of 2026-05-03 — none were dormant.
|
||||
# Symptom of the prune: today's a2a-sdk strict-mode fix
|
||||
# (#2566 / commit e1628c4) cascaded to 4 templates but never
|
||||
# reached langgraph, so the synth-E2E correctly canary'd a fix
|
||||
# that had landed but not deployed. Re-added the 5 templates.
|
||||
# Long-term: derive this list from manifest.json so cascade
|
||||
# scope can't drift from E2E scope — tracked in RFC #388 as a
|
||||
# Phase-1 invariant.
|
||||
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
|
||||
FAILED=""
|
||||
for tpl in $TEMPLATES; do
|
||||
REPO="Molecule-AI/molecule-ai-workspace-template-$tpl"
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# workspace-server/Dockerfile expects
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
# The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
|
||||
# github-app-auth. Falls back to the default token for the (rare)
|
||||
# case where an operator made the plugin repo public.
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
# drifted 10 days behind staging — same class of bug, different
|
||||
# mechanism.
|
||||
- name: Build & push platform image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify
|
||||
|
||||
- name: Build & push tenant image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile.tenant
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify RAILWAY_AUDIT_TOKEN present
|
||||
# Schedule trigger: hard-fail when the secret is missing —
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
- name: Open / update drift issue
|
||||
if: failure() && steps.audit.outputs.rc == '1'
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
AUDIT_LOG: ${{ steps.audit.outputs.log }}
|
||||
with:
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
# issue with a confirmation comment so the queue doesn't carry
|
||||
# stale ones.
|
||||
if: success() && steps.audit.outputs.rc == '0'
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
|
||||
@@ -17,7 +17,7 @@ name: redeploy-tenants-on-main
|
||||
# 1. publish-workspace-server-image completes → new :latest in GHCR.
|
||||
# 2. This workflow fires via workflow_run, waits 30s for GHCR's
|
||||
# CDN to propagate the new tag to the region the tenants pull from.
|
||||
# 3. Calls redeploy-fleet with canary_slug=hongmingwang and a 60s
|
||||
# 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
|
||||
# soak. Canary proves the image boots; batches follow.
|
||||
# 4. Any failure aborts the rollout and leaves older tenants on the
|
||||
# prior image — safer default than half-and-half state.
|
||||
@@ -34,15 +34,34 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
description: 'Tenant image tag to deploy (e.g. "latest" or "a59f1a6c"). Defaults to latest when empty.'
|
||||
# Empty default → auto-trigger and dispatch-without-input both
|
||||
# resolve to `staging-<short_head_sha>` (the digest publish-image
|
||||
# just pushed). Pre-fix this defaulted to 'latest', which only
|
||||
# gets retagged by canary-verify's promote-to-latest job — and
|
||||
# that job soft-skips when CANARY_TENANT_URLS is unset (the
|
||||
# current state, until Phase 2 canary fleet is live). Result:
|
||||
# `:latest` had been pinned to a 4-day-old digest (2026-04-28)
|
||||
# while every main push pushed fresh `staging-<sha>` images;
|
||||
# every prod redeploy pulled the stale `:latest` and the verify
|
||||
# step correctly flagged 3/3 tenants STALE. Pulling the
|
||||
# just-published `staging-<sha>` directly skips the dead retag
|
||||
# path. When canary fleet is real, this workflow should chain
|
||||
# on canary-verify completion (workflow_run from canary-verify),
|
||||
# not publish-image — separate, smaller PR.
|
||||
description: 'Tenant image tag to deploy (e.g. "latest", "staging-a59f1a6c"). Empty = auto staging-<head_sha>.'
|
||||
required: false
|
||||
type: string
|
||||
default: 'latest'
|
||||
default: ''
|
||||
canary_slug:
|
||||
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately).'
|
||||
required: false
|
||||
type: string
|
||||
default: 'hongmingwang'
|
||||
# Must be an actual prod tenant slug (current: hongming,
|
||||
# chloe-dong, reno-stars). The previous default 'hongmingwang'
|
||||
# didn't match any tenant — CP soft-skipped the missing canary
|
||||
# and the fleet rolled out without the soak gate, defeating the
|
||||
# whole point of canary-first.
|
||||
default: 'hongming'
|
||||
soak_seconds:
|
||||
description: 'Seconds to wait after canary before fanning out.'
|
||||
required: false
|
||||
@@ -91,12 +110,40 @@ jobs:
|
||||
steps:
|
||||
- name: Wait for GHCR tag propagation
|
||||
# GHCR's edge cache takes ~15-30s to consistently serve the new
|
||||
# :latest manifest after the registry accepts the push. Without
|
||||
# this sleep, the first tenant's docker pull sometimes races
|
||||
# and fetches the previous digest; sleeping is the cheapest
|
||||
# way to reduce that without polling GHCR for the new digest.
|
||||
# manifest after the registry accepts the push. Without this
|
||||
# sleep, the first tenant's docker pull sometimes races and
|
||||
# fetches the previous digest; sleeping is the cheapest way to
|
||||
# reduce that without polling GHCR for the new digest.
|
||||
run: sleep 30
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
# Resolution order:
|
||||
# 1. Operator-supplied input (workflow_dispatch with explicit
|
||||
# tag) → used verbatim. Lets ops pin `latest` for emergency
|
||||
# rollback to last canary-verified digest, or pin a specific
|
||||
# `staging-<sha>` to roll back to a known-good build.
|
||||
# 2. Default → `staging-<short_head_sha>`. The just-published
|
||||
# digest. Bypasses the `:latest` retag path that's currently
|
||||
# dead (canary-verify soft-skips without canary fleet, so
|
||||
# the only thing retagging `:latest` today is the manual
|
||||
# promote-latest.yml — last run 2026-04-28). Auto-trigger
|
||||
# from workflow_run uses workflow_run.head_sha; manual
|
||||
# dispatch with no input falls through to github.sha.
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.target_tag }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Using operator-pinned tag: $INPUT_TAG"
|
||||
else
|
||||
SHORT="${HEAD_SHA:0:7}"
|
||||
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
|
||||
echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
fi
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
|
||||
# Molecule-AI/molecule-core, matching the staging/prod CP's
|
||||
@@ -105,8 +152,8 @@ jobs:
|
||||
env:
|
||||
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'latest' }}
|
||||
CANARY_SLUG: ${{ inputs.canary_slug || 'hongmingwang' }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
|
||||
DRY_RUN: ${{ inputs.dry_run || false }}
|
||||
@@ -209,7 +256,7 @@ jobs:
|
||||
# workflow_run.head_sha is the SHA that just published.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'latest' }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
# Tenant subdomain template — slugs from the response are
|
||||
# appended. Production CP issues `<slug>.moleculesai.app`;
|
||||
# staging CP issues `<slug>.staging.moleculesai.app`. This
|
||||
@@ -218,13 +265,20 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$TARGET_TAG" != "latest" ] && [ "$TARGET_TAG" != "$EXPECTED_SHA" ]; then
|
||||
EXPECTED_SHORT="${EXPECTED_SHA:0:7}"
|
||||
if [ "$TARGET_TAG" != "latest" ] \
|
||||
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
|
||||
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
|
||||
# workflow_dispatch with a pinned tag that isn't the head
|
||||
# SHA — operator is rolling back / pinning. Skip the
|
||||
# verification because we don't have the expected SHA in
|
||||
# this context (would need to crane-inspect the GHCR
|
||||
# manifest, which is a follow-up). Failing-open here is
|
||||
# safe: the operator chose the tag deliberately.
|
||||
#
|
||||
# `staging-<short_head_sha>` IS verified — it's the new
|
||||
# auto-trigger default (see Compute target tag step) and
|
||||
# the digest under that tag SHOULD match EXPECTED_SHA.
|
||||
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -172,12 +172,50 @@ jobs:
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
# Distinguish "real fleet failure" from "E2E teardown race".
|
||||
#
|
||||
# CP returns HTTP 500 + ok=false whenever ANY tenant in the
|
||||
# fleet failed SSM or healthz. In practice the recurring source
|
||||
# of these is ephemeral test tenants being torn down by their
|
||||
# parent E2E run mid-redeploy: the EC2 dies → SSM exit=2 or
|
||||
# healthz timeout → CP marks the fleet failed → this workflow
|
||||
# goes red even though every operator-facing tenant rolled fine.
|
||||
#
|
||||
# Ephemeral slug prefixes (kept in sync with sweep-stale-e2e-orgs.yml
|
||||
# — see that file for the source-of-truth list and rationale):
|
||||
# - e2e-* — canvas/saas/ext E2E suites
|
||||
# - rt-e2e-* — runtime-test harness fixtures (RFC #2251)
|
||||
# Long-lived prefixes that are NOT ephemeral and MUST hard-fail:
|
||||
# demo-prep, dryrun-*, dryrun2-*, plus all human tenant slugs.
|
||||
#
|
||||
# Filter: if HTTP=500/ok=false AND every failed slug matches an
|
||||
# ephemeral prefix, treat as soft-warn and let the verify step
|
||||
# downstream handle unreachable-vs-stale (#2402). Any non-ephemeral
|
||||
# failure or a non-500 HTTP response remains a hard failure.
|
||||
OK=$(jq -r '.ok // "false"' "$HTTP_RESPONSE")
|
||||
FAILED_SLUGS=$(jq -r '
|
||||
.results[]?
|
||||
| select((.healthz_ok != true) or (.ssm_status != "Success"))
|
||||
| .slug' "$HTTP_RESPONSE" 2>/dev/null || true)
|
||||
EPHEMERAL_PREFIX_RE='^(e2e-|rt-e2e-)'
|
||||
NON_EPHEMERAL_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -Ev "$EPHEMERAL_PREFIX_RE" || true)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
|
||||
: # happy path — fall through to verification
|
||||
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_EPHEMERAL_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
|
||||
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -Ec "$EPHEMERAL_PREFIX_RE" || true)
|
||||
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is ephemeral (e2e-*/rt-e2e-*) — treating as teardown race, soft-warning."
|
||||
printf '%s\n' "$FAILED_SLUGS" | sed 's/^/::warning:: failed: /'
|
||||
elif [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
if [ -n "$NON_EPHEMERAL_FAILED" ]; then
|
||||
echo "::error::non-ephemeral tenant(s) failed:"
|
||||
printf '%s\n' "$NON_EPHEMERAL_FAILED" | sed 's/^/::error:: /'
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
OK=$(jq -r '.ok' "$HTTP_RESPONSE")
|
||||
if [ "$OK" != "true" ]; then
|
||||
else
|
||||
# HTTP=200 but ok=false (shouldn't happen with current CP
|
||||
# but keep the gate for completeness).
|
||||
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -26,11 +26,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire for bot-authored PRs. Human CEO PRs (staging→main promotion)
|
||||
# are intentional and pass through.
|
||||
#
|
||||
# Head-ref guard: never retarget a PR whose head IS `staging` — those
|
||||
# are the auto-promote staging→main PRs (opened by molecule-ai[bot]
|
||||
# since #2586 switched to an App token, which now passes the bot
|
||||
# filter below). Retargeting head=staging onto base=staging fails
|
||||
# with HTTP 422 "no new commits between base 'staging' and head
|
||||
# 'staging'", which used to surface as a noisy red workflow run on
|
||||
# every auto-promote (caught 2026-05-03 on PR #2588).
|
||||
if: >-
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
|| endsWith(github.event.pull_request.user.login, '[bot]')
|
||||
|| github.event.pull_request.user.login == 'app/molecule-ai'
|
||||
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
|
||||
github.event.pull_request.head.ref != 'staging'
|
||||
&& (
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
|| endsWith(github.event.pull_request.user.login, '[bot]')
|
||||
|| github.event.pull_request.user.login == 'app/molecule-ai'
|
||||
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
|
||||
)
|
||||
steps:
|
||||
- name: Retarget PR base to staging
|
||||
id: retarget
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
name: PyPI-latest install + import smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
outputs:
|
||||
wheel: ${{ steps.decide.outputs.wheel }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
echo "No workspace/ / scripts/{build_runtime_package,wheel_smoke}.py / workflow changes — wheel gate satisfied without rebuilding."
|
||||
echo "::notice::PR-built wheel + import smoke no-op pass (paths filter excluded this commit)."
|
||||
- if: needs.detect-changes.outputs.wheel == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.detect-changes.outputs.wheel == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -34,6 +34,7 @@ on:
|
||||
- ".github/workflows/secret-scan.yml"
|
||||
- ".github/workflows/secret-pattern-drift.yml"
|
||||
- ".github/scripts/lint_secret_pattern_drift.py"
|
||||
- ".githooks/pre-commit"
|
||||
workflow_dispatch:
|
||||
|
||||
# GITHUB_TOKEN scoped to read-only. The lint only does git checkout
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2 # need previous commit to diff against on push events
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Sweep stale AWS Secrets Manager secrets
|
||||
|
||||
# Janitor for per-tenant AWS Secrets Manager secrets
|
||||
# (`molecule/tenant/<org_id>/bootstrap`) whose backing tenant no
|
||||
# longer exists. Parallel-shape to sweep-cf-tunnels.yml and
|
||||
# sweep-cf-orphans.yml — different cloud, same justification.
|
||||
#
|
||||
# Why this exists separately from a long-term reconciler integration:
|
||||
# - molecule-controlplane's tenant_resources audit table (mig 024)
|
||||
# currently tracks four resource kinds: CloudflareTunnel,
|
||||
# CloudflareDNS, EC2Instance, SecurityGroup. SecretsManager is
|
||||
# not in the list, so the existing reconciler doesn't catch
|
||||
# orphan secrets.
|
||||
# - At ~$0.40/secret/month the cost grew to ~$19/month before this
|
||||
# sweeper was written, indicating ~45+ orphan secrets from
|
||||
# crashed provisions and incomplete deprovision flows.
|
||||
# - The proper fix (KindSecretsManagerSecret + recorder hook +
|
||||
# reconciler enumerator) is filed as a separate controlplane
|
||||
# issue. This sweeper is the immediate cost-relief stopgap.
|
||||
#
|
||||
# IAM principal: AWS_JANITOR_ACCESS_KEY_ID / AWS_JANITOR_SECRET_ACCESS_KEY.
|
||||
# This is a DEDICATED principal — the production `molecule-cp` IAM
|
||||
# user lacks `secretsmanager:ListSecrets` (it only has
|
||||
# Get/Create/Update/Delete on specific resources, scoped to its
|
||||
# operational needs). The janitor needs ListSecrets across the
|
||||
# `molecule/tenant/*` prefix, which warrants a separate principal so
|
||||
# we don't broaden the prod-CP policy.
|
||||
#
|
||||
# Safety: the script's MAX_DELETE_PCT gate (default 50%, mirroring
|
||||
# sweep-cf-orphans.yml — tenant secrets are durable by design, unlike
|
||||
# the mostly-orphan tunnels) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
|
||||
# sweep-cf-tunnels (:45) so the three janitors don't burst the
|
||||
# CP admin endpoints at the same minute.
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted, no deletion"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
max_delete_pct:
|
||||
description: "Override safety gate (default 50, set higher only for major cleanup)"
|
||||
required: false
|
||||
default: "50"
|
||||
grace_hours:
|
||||
description: "Skip secrets created within this many hours (default 24)"
|
||||
required: false
|
||||
default: "24"
|
||||
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
group: sweep-aws-secrets
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep AWS Secrets Manager
|
||||
runs-on: ubuntu-latest
|
||||
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
|
||||
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
|
||||
# under the 8-way xargs parallelism, but the cap is set generously
|
||||
# to leave headroom for any actual API hang.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_JANITOR_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_JANITOR_SECRET_ACCESS_KEY }}
|
||||
CP_PROD_ADMIN_TOKEN: ${{ secrets.CP_PROD_ADMIN_TOKEN }}
|
||||
CP_STAGING_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_TOKEN }}
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
# Schedule-vs-dispatch behaviour split mirrors sweep-cf-orphans
|
||||
# and sweep-cf-tunnels (hardened 2026-04-28). Same principle:
|
||||
# - schedule → exit 1 on missing secrets (red CI surfaces it)
|
||||
# - workflow_dispatch → exit 0 with warning (operator-driven,
|
||||
# they already accepted the repo state)
|
||||
run: |
|
||||
missing=()
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
|
||||
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
|
||||
echo "::warning::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/* (the prod molecule-cp principal lacks ListSecrets)."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
|
||||
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
|
||||
echo "::error::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/*."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets present ✓"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run sweep
|
||||
if: steps.verify.outputs.skip != 'true'
|
||||
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
|
||||
# - Scheduled: input empty → "false" → --execute (the whole
|
||||
# point of an hourly janitor).
|
||||
# - Manual workflow_dispatch: input default true → dry-run;
|
||||
# operator must flip it to actually delete.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
|
||||
echo "Running in dry-run mode — no deletions"
|
||||
bash scripts/ops/sweep-aws-secrets.sh
|
||||
else
|
||||
echo "Running with --execute — will delete identified orphans"
|
||||
bash scripts/ops/sweep-aws-secrets.sh --execute
|
||||
fi
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
|
||||
@@ -47,10 +47,22 @@ jobs:
|
||||
sweep:
|
||||
name: Sweep CF tunnels
|
||||
runs-on: ubuntu-latest
|
||||
# 5 min surfaces hangs (CF API stall, slow pagination on busy
|
||||
# accounts). Realistic worst case is ~3 min: 2 CP curls + N CF
|
||||
# list pages + N×CF-DELETE, each capped at 10-15s by curl -m.
|
||||
timeout-minutes: 5
|
||||
# 30 min cap. Was 5 min on the theory that the only thing that
|
||||
# could take >5min is a CF-API hang — but on 2026-05-02 a backlog
|
||||
# of 672 stale tunnels accumulated (large staging E2E run + delayed
|
||||
# sweep) and the serial `curl -X DELETE` loop (~0.7s/tunnel) needed
|
||||
# ~7-8min to drain. The 5-min cap killed the run mid-sweep
|
||||
# (cancelled at 424/672, see run 25248788312); a manual rerun
|
||||
# finished the remainder fine.
|
||||
#
|
||||
# The fix is two-part: parallelize the delete loop (8-way xargs in
|
||||
# the script — see scripts/ops/sweep-cf-tunnels.sh), AND raise the
|
||||
# cap so a one-off backlog doesn't trip a hangs-detector that
|
||||
# turned out to be a real-job-too-slow detector. With 8-way
|
||||
# parallelism, 600+ tunnels drains in ~60s; 30 min is generous
|
||||
# headroom for actual hangs to still surface (and is in line with
|
||||
# the sweep-cf-orphans companion job).
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
@@ -59,7 +71,7 @@ jobs:
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '90' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
|
||||
@@ -25,16 +25,23 @@ name: Sweep stale e2e-* orgs (staging)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every hour on the hour. E2E orgs are short-lived (~10-25 min wall
|
||||
# clock from create to teardown). Anything older than the
|
||||
# MAX_AGE_MINUTES threshold below is presumed dead.
|
||||
- cron: '0 * * * *'
|
||||
# Every 15 min. E2E orgs are short-lived (~8-25 min wall clock from
|
||||
# create to teardown — canary is ~8 min, full SaaS ~25 min). The
|
||||
# previous hourly + 120-min stale threshold meant a leaked tenant
|
||||
# could keep an EC2 alive for up to 2 hours, eating ~2 vCPU per
|
||||
# leak. Tightening the cadence + threshold reduces the worst-case
|
||||
# leak window from 120 min to ~45 min (15-min sweep cadence + 30-min
|
||||
# threshold) without risk of catching in-progress runs (the longest
|
||||
# e2e run is the 25-min canary, well under the 30-min threshold).
|
||||
# See molecule-controlplane#420 for the leak-class accounting that
|
||||
# motivated this tightening.
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_age_minutes:
|
||||
description: "Delete e2e-* orgs older than N minutes (default 120)"
|
||||
description: "Delete e2e-* orgs older than N minutes (default 30)"
|
||||
required: false
|
||||
default: "120"
|
||||
default: "30"
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted"
|
||||
required: false
|
||||
@@ -58,7 +65,7 @@ jobs:
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '120' }}
|
||||
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '30' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
# Refuse to delete more than this many orgs in one tick. If the
|
||||
# CP DB is briefly empty (or the admin endpoint goes weird and
|
||||
@@ -87,20 +94,28 @@ jobs:
|
||||
> orgs.json
|
||||
|
||||
# Filter:
|
||||
# 1. slug starts with 'e2e-' (covers e2e-, e2e-canary-,
|
||||
# e2e-canvas-* — all variants the test scripts mint)
|
||||
# 1. slug starts with one of the ephemeral test prefixes:
|
||||
# - 'e2e-' — covers e2e-canary-, e2e-canvas-*, etc.
|
||||
# - 'rt-e2e-' — runtime-test harness fixtures (RFC #2251);
|
||||
# missing this prefix left two such tenants
|
||||
# orphaned 8h on staging (2026-05-03), then
|
||||
# hard-failed redeploy-tenants-on-staging
|
||||
# and broke the staging→main auto-promote
|
||||
# chain. Kept in sync with the EPHEMERAL_PREFIX_RE
|
||||
# regex in redeploy-tenants-on-staging.yml.
|
||||
# 2. created_at is older than MAX_AGE_MINUTES ago
|
||||
# Output one slug per line to a file the next step reads.
|
||||
python3 > stale_slugs.txt <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
|
||||
with open("orgs.json") as f:
|
||||
data = json.load(f)
|
||||
max_age = int(os.environ["MAX_AGE_MINUTES"])
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age)
|
||||
for o in data.get("orgs", []):
|
||||
slug = o.get("slug", "")
|
||||
if not slug.startswith("e2e-"):
|
||||
if not slug.startswith(EPHEMERAL_PREFIXES):
|
||||
continue
|
||||
created = o.get("created_at")
|
||||
if not created:
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
name: Ops scripts (unittest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": false
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
|
||||
@@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
orgID = row.id;
|
||||
return true;
|
||||
}
|
||||
if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`);
|
||||
if (row.instance_status === "failed") {
|
||||
// Dump every diagnostic field the admin row carries — boot stage,
|
||||
// last error, terraform/SSM state, etc. The bare slug message used
|
||||
// to surface ZERO context, so triaging a failed provision meant
|
||||
// re-running locally to repro. Now the failure log carries enough
|
||||
// to point at the right subsystem (CP/AWS/SSM/runtime) without a
|
||||
// second round-trip.
|
||||
throw new Error(
|
||||
`provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
PROVISION_TIMEOUT_MS,
|
||||
@@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
if (r.status !== 200) return null;
|
||||
if (r.body?.status === "online") return true;
|
||||
if (r.body?.status === "failed") {
|
||||
throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`);
|
||||
// last_sample_error is often empty when the failure happens before
|
||||
// the agent emits a sample (e.g. boot crash, image pull error,
|
||||
// missing PYTHONPATH, OpenAI quota at startup). Dumping the full
|
||||
// body gives triage the boot_stage / last_error / image fields it
|
||||
// needs without a second probe. Otherwise this propagates as a
|
||||
// bare "Workspace failed: " — the exact useless message that
|
||||
// sent #2632 to the issue tracker.
|
||||
const detail = r.body.last_sample_error
|
||||
? r.body.last_sample_error
|
||||
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
|
||||
throw new Error(`Workspace failed: ${detail}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
Generated
+389
-984
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -37,10 +37,10 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^29.1.0",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.13",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
+125
-13
@@ -1,28 +1,139 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/*
|
||||
* Tailwind v4 defaults the `dark:` variant to `prefers-color-scheme: dark`.
|
||||
* Our theme switcher writes `data-theme="dark"` on <html> instead (so user
|
||||
* choice via the toggle wins over OS preference). Re-bind `dark:` to that
|
||||
* attribute so component classes like `dark:bg-zinc-800` track the same
|
||||
* source of truth as the `[data-theme="dark"]` token overrides below.
|
||||
*/
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
/*
|
||||
* Load order:
|
||||
* 1. Tailwind core (v4) — provides preflight + utility generation.
|
||||
* 2. xterm — overrides preflight on its own .xterm-* class names; must
|
||||
* load AFTER tailwind so its specificity wins.
|
||||
* 3. theme-tokens.css — canvas-only motion + deploy animation vars
|
||||
* (--mol-duration-*, --mol-easing-*, --mol-deploy-*). NOT colour
|
||||
* tokens; the warm-paper @theme block below owns those.
|
||||
* 4. settings-panel.css / org-deploy.css — feature stylesheets that
|
||||
* reference the variables above.
|
||||
*/
|
||||
@import "xterm/css/xterm.css";
|
||||
/* Theme tokens MUST load before any feature stylesheet that
|
||||
references them so custom properties are in scope. */
|
||||
@import "../styles/theme-tokens.css";
|
||||
@import "../styles/settings-panel.css";
|
||||
@import "../styles/org-deploy.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/*
|
||||
* Warm-paper semantic tokens — light defaults via @theme, dark
|
||||
* overrides via [data-theme="dark"]. Names are role-based
|
||||
* (`bg-surface`, `text-ink`, `border-line`) not colour-based, so the
|
||||
* same component classes work in either mode.
|
||||
*
|
||||
* Source of truth: molecule-app/app/globals.css. Keep aligned across
|
||||
* surfaces (landing, market, app, canvas) so a token tweak ripples
|
||||
* everywhere via a single PR per repo.
|
||||
*
|
||||
* Theme preference is persisted in the `mol_theme` cookie scoped to
|
||||
* Domain=.moleculesai.app so the choice follows the user across
|
||||
* subdomains. The inline boot script in app/layout.tsx applies it
|
||||
* before paint to eliminate flash.
|
||||
*/
|
||||
@theme {
|
||||
/* Surface — page / elevated card / sunken input / deep card */
|
||||
--color-surface: #fafaf7;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-sunken: #f3f1ec;
|
||||
--color-surface-card: #efece4;
|
||||
|
||||
/* Borders */
|
||||
--color-line: #e6e2d8;
|
||||
--color-line-soft: #efece4;
|
||||
|
||||
/* Text */
|
||||
--color-ink: #15181c;
|
||||
--color-ink-mid: #5a5e66;
|
||||
--color-ink-soft: #8b8e95;
|
||||
|
||||
/* Brand + state */
|
||||
--color-accent: #3b5bdb;
|
||||
--color-accent-strong: #1a2f99;
|
||||
--color-warm: #c0532b;
|
||||
--color-good: #2f7a4d;
|
||||
--color-bad: #b94e4a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-surface: #0e1014;
|
||||
--color-surface-elevated: #15181c;
|
||||
--color-surface-sunken: #0a0b0e;
|
||||
--color-surface-card: #1a1d23;
|
||||
|
||||
--color-line: #2a2f3a;
|
||||
--color-line-soft: #1f2329;
|
||||
|
||||
--color-ink: #f4f1e9;
|
||||
--color-ink-mid: #c8c2b4;
|
||||
--color-ink-soft: #8d92a0;
|
||||
|
||||
/* Accents brighten slightly for AA contrast on dark backgrounds. */
|
||||
--color-accent: #6883e8;
|
||||
--color-accent-strong: #8aa1ee;
|
||||
--color-warm: #d96f48;
|
||||
--color-good: #4ca06e;
|
||||
--color-bad: #d27773;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
* Always-dark surface tokens. Terminals (xterm), the console modal,
|
||||
* and log streams stay dark in both modes — readable green-on-black
|
||||
* code surfaces don't translate cleanly to a light theme. Components
|
||||
* that should not light-flip use `bg-bg`, `bg-bg-elev`, `bg-bg-card`,
|
||||
* `text-ink-mute`, `text-ink-dim`, `border-line-strong` instead of
|
||||
* the warm-paper utilities above.
|
||||
*
|
||||
* Distinct names (bg-* / ink-mute / ink-dim / line-strong) so they
|
||||
* don't collide with the warm-paper namespace (surface / ink /
|
||||
* line). Both palettes coexist; the choice between them is per
|
||||
* component, not per theme.
|
||||
*/
|
||||
@theme {
|
||||
--color-bg: rgb(9 9 11); /* zinc-950 */
|
||||
--color-bg-elev: rgb(24 24 27); /* zinc-900 */
|
||||
--color-bg-card: rgb(39 39 42); /* zinc-800 */
|
||||
--color-line-strong: rgb(63 63 70); /* zinc-700 */
|
||||
--color-ink-mute: rgb(161 161 170); /* zinc-400 */
|
||||
--color-ink-dim: rgb(113 113 122); /* zinc-500 */
|
||||
--color-accent-dim: rgb(96 165 250);/* blue-400 */
|
||||
--color-plasma: rgb(59 130 246); /* blue-500 */
|
||||
--color-warn: rgb(251 191 36); /* amber-400 */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #09090b;
|
||||
color: #e4e4e7;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* React Flow overrides for dark theme */
|
||||
/* React Flow overrides for both themes. Edge stroke pulls from the
|
||||
semantic line token so dark mode keeps its existing zinc-700 look
|
||||
and light mode picks up the warm-paper line colour. */
|
||||
.react-flow__edge-path {
|
||||
stroke: #3f3f46 !important;
|
||||
stroke: var(--color-line) !important;
|
||||
stroke-width: 1.5 !important;
|
||||
}
|
||||
|
||||
@@ -58,7 +169,8 @@ body {
|
||||
transition: box-shadow var(--mol-duration-fast) ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
/* Scrollbar styling. Track + thumb pull from the surface tokens so
|
||||
they feel native to either theme. */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -69,17 +181,17 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
background: var(--color-line);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
background: var(--color-line-strong, var(--color-ink-soft));
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Panel slide animation */
|
||||
|
||||
+56
-16
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { AuthGate } from "@/components/AuthGate";
|
||||
import { CookieConsent } from "@/components/CookieConsent";
|
||||
import { ThemeProvider } from "@/lib/theme-provider";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
readThemeCookie,
|
||||
themeBootScript,
|
||||
} from "@/lib/theme-cookie";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Molecule AI",
|
||||
@@ -15,7 +21,7 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Read the per-request CSP nonce that middleware.ts sets via the
|
||||
// `x-nonce` request header. This call is load-bearing for TWO
|
||||
// `x-nonce` request header. This call is load-bearing for THREE
|
||||
// independent reasons:
|
||||
//
|
||||
// 1. It opts the root layout into dynamic rendering. Without a
|
||||
@@ -31,22 +37,56 @@ export default async function RootLayout({
|
||||
// is actually read via `headers()`. The header's existence on
|
||||
// the request isn't enough — Next.js watches for the read.
|
||||
//
|
||||
// Keeping the `nonce` variable unused is intentional: we don't need
|
||||
// to pass it to any custom <Script nonce={...}> tags right now, the
|
||||
// framework takes care of its own bootstrap scripts once the read
|
||||
// happens. Destructuring via `await` + `.get()` is the minimum shape
|
||||
// Next.js recognizes as "dynamic server-side access".
|
||||
await headers();
|
||||
// 3. We need the nonce to attach to the inline theme boot script
|
||||
// below, otherwise CSP rejects it in production where
|
||||
// script-src is `'self' 'nonce-{nonce}' 'strict-dynamic'`.
|
||||
// 'strict-dynamic' propagates trust from a nonce'd script to
|
||||
// scripts it inserts, but does NOT forgive an un-nonce'd
|
||||
// sibling — the boot script must carry its own nonce.
|
||||
const hdrs = await headers();
|
||||
const nonce = hdrs.get("x-nonce") ?? undefined;
|
||||
|
||||
// SSR: read the user's saved preference. For light/dark we can stamp
|
||||
// data-theme on <html> here so the very first paint matches; for
|
||||
// "system" we leave the attribute off and let the inline boot script
|
||||
// resolve from matchMedia before paint.
|
||||
const cookieStore = await cookies();
|
||||
const theme = readThemeCookie(cookieStore.get(THEME_COOKIE)?.value);
|
||||
const initialDataTheme = theme === "system" ? undefined : theme;
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-zinc-950 text-white">
|
||||
{/* AuthGate is a client component; it checks the session on mount
|
||||
and bounces anonymous users to the control plane's login page
|
||||
when running on a tenant subdomain. Non-SaaS hosts (localhost,
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
<CookieConsent />
|
||||
// suppressHydrationWarning on <html>: the inline boot script below
|
||||
// mutates `data-theme` before React hydrates (system mode reads
|
||||
// matchMedia + writes the attribute). That's the entire point of the
|
||||
// script — eliminate the flash — and it's the documented escape hatch
|
||||
// for "the server-rendered HTML is intentionally not what React would
|
||||
// produce client-side at this exact attribute."
|
||||
<html lang="en" data-theme={initialDataTheme} suppressHydrationWarning>
|
||||
<head>
|
||||
{/*
|
||||
* Boot script: runs synchronously before the body paints, sets
|
||||
* data-theme on <html> for "system" preference based on the OS
|
||||
* media query. For explicit light/dark, SSR already set the
|
||||
* attribute above and the script's write is a no-op.
|
||||
*
|
||||
* `nonce` comes from middleware's per-request CSP nonce — see
|
||||
* the comment block above for why CSP requires this even though
|
||||
* the page also has 'strict-dynamic'.
|
||||
*/}
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
</head>
|
||||
<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
|
||||
when running on a tenant subdomain. Non-SaaS hosts (localhost,
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
<CookieConsent />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -110,15 +110,15 @@ export default function OrgsPage() {
|
||||
}, []);
|
||||
|
||||
if (session === "loading" || (orgs === null && error === null)) {
|
||||
return <Shell><p className="text-zinc-400">Loading…</p></Shell>;
|
||||
return <Shell><p className="text-ink-mid">Loading…</p></Shell>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Shell>
|
||||
<p role="alert" className="text-red-400">Error: {error}</p>
|
||||
<p role="alert" className="text-bad">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
|
||||
className="mt-4 rounded bg-surface-card px-4 py-2 text-sm text-ink hover:bg-surface-card"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -136,7 +136,7 @@ export default function OrgsPage() {
|
||||
<OrgRow key={o.id} org={o} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-8 border-t border-zinc-800 pt-6">
|
||||
<div className="mt-8 border-t border-line pt-6">
|
||||
<CreateOrgForm
|
||||
onCreated={(slug) => {
|
||||
// Refresh the list so the new org appears + its CTA fires.
|
||||
@@ -162,11 +162,11 @@ function CheckoutBanner() {
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<main className="min-h-screen bg-surface text-ink">
|
||||
<TermsGate>
|
||||
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
|
||||
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
<h1 className="text-3xl font-bold text-ink">Your organizations</h1>
|
||||
<p className="mt-2 text-ink-mid">
|
||||
Each org is an isolated Molecule workspace.
|
||||
</p>
|
||||
<DataResidencyNotice />
|
||||
@@ -184,7 +184,7 @@ function Shell({ children }: { children: React.ReactNode }) {
|
||||
// region dropdown.
|
||||
function DataResidencyNotice() {
|
||||
return (
|
||||
<p className="mt-3 rounded border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-xs text-zinc-400">
|
||||
<p className="mt-3 rounded border border-line bg-surface-sunken/60 px-3 py-2 text-xs text-ink-mid">
|
||||
Workspaces run in AWS us-east-2 (Ohio, United States). EU region support is on the roadmap — reach out to
|
||||
{" "}
|
||||
<a href="mailto:support@moleculesai.app" className="underline">
|
||||
@@ -197,11 +197,11 @@ function DataResidencyNotice() {
|
||||
|
||||
function OrgRow({ org }: { org: Org }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<li className="rounded-lg border border-line bg-surface-sunken p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-white">{org.name}</div>
|
||||
<div className="text-sm text-zinc-400">
|
||||
<div className="font-medium text-ink">{org.name}</div>
|
||||
<div className="text-sm text-ink-mid">
|
||||
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
@@ -237,21 +237,21 @@ function LowCreditsBanner({ org }: { org: Org }) {
|
||||
if (kind === "overage") {
|
||||
const used = (org.overage_used_credits ?? 0).toLocaleString();
|
||||
return (
|
||||
<span className="text-xs text-amber-300">
|
||||
<span className="text-xs text-warm">
|
||||
overage active · {used} used
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (kind === "out-of-credits") {
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-red-300 underline">
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-bad underline">
|
||||
out of credits — upgrade to keep running
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// trial-tail
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-amber-300 underline">
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-warm underline">
|
||||
trial almost out
|
||||
</a>
|
||||
);
|
||||
@@ -260,11 +260,11 @@ function LowCreditsBanner({ org }: { org: Org }) {
|
||||
function StatusLabel({ status }: { status: OrgStatus }) {
|
||||
const cls =
|
||||
status === "running"
|
||||
? "text-emerald-400"
|
||||
? "text-good"
|
||||
: status === "awaiting_payment"
|
||||
? "text-amber-400"
|
||||
? "text-warm"
|
||||
: status === "failed"
|
||||
? "text-red-400"
|
||||
? "text-bad"
|
||||
: "text-sky-400";
|
||||
const label =
|
||||
status === "awaiting_payment"
|
||||
@@ -303,21 +303,21 @@ function OrgCTA({ org }: { org: Org }) {
|
||||
return (
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="rounded bg-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-600"
|
||||
className="rounded bg-surface-card px-4 py-2 text-sm font-medium text-ink hover:bg-surface-card"
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// provisioning / unknown — non-interactive
|
||||
return <span className="text-sm text-zinc-500">{org.status}…</span>;
|
||||
return <span className="text-sm text-ink-soft">{org.status}…</span>;
|
||||
}
|
||||
|
||||
function EmptyState({ banner }: { banner?: React.ReactNode }) {
|
||||
return (
|
||||
<Shell>
|
||||
{banner}
|
||||
<p className="text-zinc-300">
|
||||
<p className="text-ink-mid">
|
||||
You don't have any organizations yet. Create one to get started — your
|
||||
workspace spins up automatically once billing is set up.
|
||||
</p>
|
||||
@@ -365,7 +365,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="org-slug" className="block text-sm text-zinc-300">Slug (URL)</label>
|
||||
<label htmlFor="org-slug" className="block text-sm text-ink-mid">Slug (URL)</label>
|
||||
<input
|
||||
id="org-slug"
|
||||
value={slug}
|
||||
@@ -374,28 +374,28 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
||||
placeholder="acme"
|
||||
required
|
||||
aria-describedby="org-slug-hint"
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
<p id="org-slug-hint" className="mt-1 text-xs text-zinc-500">
|
||||
<p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
|
||||
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="org-name" className="block text-sm text-zinc-300">Display name</label>
|
||||
<label htmlFor="org-name" className="block text-sm text-ink-mid">Display name</label>
|
||||
<input
|
||||
id="org-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
</div>
|
||||
{err && <p role="alert" className="text-sm text-red-400">{err}</p>}
|
||||
{err && <p role="alert" className="text-sm text-bad">{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
className="rounded bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create organization"}
|
||||
</button>
|
||||
|
||||
+15
-15
@@ -53,10 +53,10 @@ export default function Home() {
|
||||
|
||||
if (hydrating) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface">
|
||||
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-xs text-zinc-500">Loading canvas...</span>
|
||||
<span className="text-xs text-ink-soft">Loading canvas...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -79,15 +79,15 @@ export default function Home() {
|
||||
// selector that's used by other transient toasts. Don't rename
|
||||
// without updating that spec.
|
||||
data-testid="hydration-error"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-4 z-[9999]"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999]"
|
||||
>
|
||||
<p className="text-zinc-400 text-sm">{hydrationError}</p>
|
||||
<p className="text-ink-mid text-sm">{hydrationError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -108,28 +108,28 @@ function PlatformDownDiagnostic() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-5 z-[9999] px-6"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-5 z-[9999] px-6"
|
||||
>
|
||||
<div className="text-amber-400 text-sm font-semibold uppercase tracking-wider">
|
||||
<div className="text-warm text-sm font-semibold uppercase tracking-wider">
|
||||
Platform infrastructure unreachable
|
||||
</div>
|
||||
<p className="text-zinc-400 text-sm max-w-lg text-center leading-relaxed">
|
||||
The platform server returned <code className="font-mono text-amber-300">503 platform_unavailable</code>.
|
||||
<p className="text-ink-mid text-sm max-w-lg text-center leading-relaxed">
|
||||
The platform server returned <code className="font-mono text-warm">503 platform_unavailable</code>.
|
||||
That means it can't reach Postgres or Redis to validate your session.
|
||||
Most common cause on a dev host: one of those services stopped.
|
||||
</p>
|
||||
<div className="bg-zinc-900/80 border border-zinc-700/50 rounded-lg px-4 py-3 max-w-lg w-full">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2">Try first</div>
|
||||
<pre className="text-[12px] text-zinc-300 font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
|
||||
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
|
||||
<div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
|
||||
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
|
||||
brew services start redis`}</pre>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 max-w-lg text-center">
|
||||
<p className="text-[11px] text-ink-soft max-w-lg text-center">
|
||||
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
|
||||
the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm mt-2"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
|
||||
@@ -19,17 +19,17 @@ export const metadata = {
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<main className="min-h-screen bg-surface text-ink">
|
||||
<div className="mx-auto max-w-5xl px-6 pt-20 pb-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white md:text-6xl">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-ink md:text-6xl">
|
||||
Pricing
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-300">
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-ink-mid">
|
||||
One flat price per org — not per seat. Every paid tier includes the
|
||||
full runtime stack. You upgrade for scale, support, and dedicated
|
||||
infrastructure.
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm text-zinc-400">
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm text-ink-mid">
|
||||
5-person team? You pay $29/month — not $200. No seat math, ever.
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,42 +37,42 @@ export default function PricingPage() {
|
||||
<PricingTable />
|
||||
|
||||
<section className="mx-auto mt-20 max-w-3xl px-6 text-center">
|
||||
<h2 className="text-2xl font-semibold text-white">Questions?</h2>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
<h2 className="text-2xl font-semibold text-ink">Questions?</h2>
|
||||
<p className="mt-2 text-ink-mid">
|
||||
We publish the{" "}
|
||||
<a
|
||||
href="https://github.com/Molecule-AI/molecule-monorepo"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
className="text-accent underline hover:text-accent"
|
||||
>
|
||||
full source on GitHub
|
||||
</a>
|
||||
{" "}— if something's ambiguous, file an issue or{" "}
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
className="text-accent underline hover:text-accent"
|
||||
>
|
||||
email support
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="mt-6 text-sm text-zinc-500">
|
||||
<p className="mt-6 text-sm text-ink-soft">
|
||||
Prices shown in USD. Flat-rate per org — no per-seat fees on any paid tier.
|
||||
Enterprise / self-hosted licensing available — contact us.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-zinc-800 px-6 py-6 text-center text-sm text-zinc-500">
|
||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
|
||||
<a href="/legal/terms" className="hover:text-zinc-300">
|
||||
<a href="/legal/terms" className="hover:text-ink-mid">
|
||||
Terms
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/privacy" className="hover:text-zinc-300">
|
||||
<a href="/legal/privacy" className="hover:text-ink-mid">
|
||||
Privacy
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/dpa" className="hover:text-zinc-300">
|
||||
<a href="/legal/dpa" className="hover:text-ink-mid">
|
||||
DPA
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -138,14 +138,37 @@ export function A2ATopologyOverlay() {
|
||||
// Stable Zustand action reference — safe to call inside effects
|
||||
const setA2AEdges = useCanvasStore((s) => s.setA2AEdges);
|
||||
|
||||
// Read the nodes array as a primitive ref; derive visible IDs outside the selector
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
// Subscribe to a STABLE STRING KEY of visible workspace IDs, not the
|
||||
// nodes array itself. Zustand returns a new array reference on every
|
||||
// store update (status flips, position drags, peer-discovery writes,
|
||||
// workspace-tab opens, etc.) — even when the set of visible IDs is
|
||||
// unchanged. Selecting a sorted-CSV string makes Zustand's default
|
||||
// shallow-equal short-circuit the re-render unless the actual ID set
|
||||
// changes.
|
||||
//
|
||||
// Why this matters: previously visibleIds was useMemo'd on `nodes`, so
|
||||
// the array reference recreated on every store mutation. fetchAndUpdate
|
||||
// (useCallback'd on visibleIds) then recreated, the useEffect re-fired,
|
||||
// it tore down the 60s setInterval and immediately re-ran the fan-out.
|
||||
// With ~5 store updates/second from heartbeats + polling, the canvas
|
||||
// hammered /workspaces/<id>/activity?type=delegation 5×N requests/sec
|
||||
// until edge rate-limit kicked in with HTTP 429. The recursive React
|
||||
// render trace in the original bug report (uE → ux → uE → ux ...) is
|
||||
// the symptom of this re-render storm.
|
||||
//
|
||||
// The fix is purely the dependency-stability change here; the fetch
|
||||
// logic is unchanged.
|
||||
const visibleIdsKey = useCanvasStore((s) =>
|
||||
s.nodes
|
||||
.filter((n) => !n.hidden)
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(",")
|
||||
);
|
||||
|
||||
// IDs of visible (non-nested, non-hidden) workspace nodes.
|
||||
// Recomputed only when the nodes array reference changes.
|
||||
const visibleIds = useMemo(
|
||||
() => nodes.filter((n) => !n.hidden).map((n) => n.id),
|
||||
[nodes]
|
||||
() => (visibleIdsKey ? visibleIdsKey.split(",") : []),
|
||||
[visibleIdsKey]
|
||||
);
|
||||
|
||||
// Fetch delegation activity for all visible workspaces and rebuild overlay edges.
|
||||
|
||||
@@ -61,26 +61,31 @@ export function ApprovalBanner() {
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-amber-300 text-lg" aria-hidden="true">⚠</span>
|
||||
<span className="text-warm text-lg" aria-hidden="true">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
|
||||
{approval.reason && (
|
||||
<div className="text-xs text-amber-300/70 mt-1">{approval.reason}</div>
|
||||
<div className="text-xs text-warm/70 mt-1">{approval.reason}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded-lg text-zinc-300 transition-colors"
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
|
||||
type EventFilter = "all" | AuditEntry["event_type"];
|
||||
|
||||
const BADGE_COLORS: Record<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
|
||||
delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" },
|
||||
delegation: { text: "text-accent", bg: "bg-blue-950/40", border: "border-blue-800/40" },
|
||||
decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" },
|
||||
gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
|
||||
hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
|
||||
@@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-zinc-500">Loading audit trail…</span>
|
||||
<span className="text-xs text-ink-soft">Loading audit trail…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Filter bar */}
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -144,8 +144,8 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
||||
filter === f.id
|
||||
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
|
||||
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"
|
||||
>
|
||||
↻
|
||||
@@ -164,7 +164,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0">
|
||||
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
{entries.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">⊟</span>
|
||||
<p className="text-sm font-medium text-zinc-400">No audit events yet</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">⊟</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
Delegation, decision, gate, and human-in-the-loop events will appear here.
|
||||
</p>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
|
||||
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>
|
||||
@@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
{/* Entry count footer */}
|
||||
<p className="mt-3 text-center text-[9px] text-zinc-600">
|
||||
<p className="mt-3 text-center text-[9px] text-ink-soft">
|
||||
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
|
||||
{cursor ? " · more available" : " · all loaded"}
|
||||
</p>
|
||||
@@ -227,15 +227,15 @@ export interface AuditEntryRowProps {
|
||||
*/
|
||||
export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
const badge = BADGE_COLORS[entry.event_type] ?? {
|
||||
text: "text-zinc-400",
|
||||
bg: "bg-zinc-800/40",
|
||||
border: "border-zinc-700/40",
|
||||
text: "text-ink-mid",
|
||||
bg: "bg-surface-card/40",
|
||||
border: "border-line/40",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-2.5 space-y-1.5"
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-2.5 space-y-1.5"
|
||||
>
|
||||
{/* Header row: badge · actor · tamper flag · timestamp */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -248,14 +248,14 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
</span>
|
||||
|
||||
{/* Actor name */}
|
||||
<span className="text-[10px] text-zinc-400 truncate flex-1 min-w-0 font-mono">
|
||||
<span className="text-[10px] text-ink-mid truncate flex-1 min-w-0 font-mono">
|
||||
{entry.actor}
|
||||
</span>
|
||||
|
||||
{/* Tamper warning — only rendered when chain is invalid */}
|
||||
{!entry.chain_valid && (
|
||||
<span
|
||||
className="shrink-0 text-[11px] text-red-400 font-bold leading-none"
|
||||
className="shrink-0 text-[11px] text-bad font-bold leading-none"
|
||||
title="Chain integrity check failed — this entry may have been tampered with"
|
||||
aria-label="Chain integrity warning: tampered entry"
|
||||
role="img"
|
||||
@@ -265,13 +265,13 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
)}
|
||||
|
||||
{/* Relative timestamp */}
|
||||
<span className="shrink-0 text-[9px] text-zinc-600">
|
||||
<span className="shrink-0 text-[9px] text-ink-soft">
|
||||
{formatAuditRelativeTime(entry.created_at, now)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary text */}
|
||||
<p className="text-[11px] text-zinc-300 leading-relaxed break-words">
|
||||
<p className="text-[11px] text-ink-mid leading-relaxed break-words">
|
||||
{entry.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (state.kind === "loading") {
|
||||
// Zinc-950 backdrop matches the canvas background so the browser
|
||||
// never paints a white flash while the session round-trip resolves.
|
||||
return <div className="fixed inset-0 bg-zinc-950" aria-hidden="true" />;
|
||||
return <div className="fixed inset-0 bg-surface" aria-hidden="true" />;
|
||||
}
|
||||
if (state.kind === "anonymous" && !state.skipRedirect) {
|
||||
// Redirect already firing from the effect above; render nothing in
|
||||
|
||||
@@ -30,6 +30,24 @@ export function BatchActionBar() {
|
||||
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
|
||||
}, [count, hasFailedBatch]);
|
||||
|
||||
// Esc clears selection — the deselect button title has been promising
|
||||
// "(Escape)" since the bar shipped, but no handler was wired. Skip when
|
||||
// the confirm dialog is open (`pending !== null`) so the dialog's own
|
||||
// Esc-cancels takes precedence and we don't double-handle the keystroke.
|
||||
// Also skip during a busy in-flight action so the user can't accidentally
|
||||
// strand a partial-failure mid-flight.
|
||||
useEffect(() => {
|
||||
if (count === 0 || pending !== null || busy) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [count, pending, busy, clearSelection]);
|
||||
|
||||
// Hide when nothing is selected. Hide for single-node selection UNLESS a
|
||||
// partial-failure left a survivor awaiting retry.
|
||||
if (count === 0) return null;
|
||||
@@ -80,14 +98,14 @@ export function BatchActionBar() {
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Batch workspace actions"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-zinc-900/95 border border-zinc-700/70 shadow-2xl shadow-black/50 backdrop-blur-md"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
|
||||
>
|
||||
{/* Selection count badge */}
|
||||
<span className="text-[12px] font-semibold text-zinc-100 bg-blue-600/80 px-2.5 py-0.5 rounded-full tabular-nums">
|
||||
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
|
||||
{count} selected
|
||||
</span>
|
||||
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
@@ -104,7 +122,7 @@ export function BatchActionBar() {
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("pause")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
>
|
||||
<span aria-hidden="true">⏸</span>
|
||||
Pause All
|
||||
@@ -114,13 +132,13 @@ export function BatchActionBar() {
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
Delete All
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
|
||||
|
||||
{/* Deselect */}
|
||||
<button
|
||||
@@ -129,7 +147,7 @@ export function BatchActionBar() {
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection (Escape)"
|
||||
className="p-1.5 rounded-lg text-[12px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
|
||||
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -112,27 +112,40 @@ export function BundleDropZone() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Import bundle file"
|
||||
aria-controls="bundle-file-input"
|
||||
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
|
||||
className="sr-only focus:not-sr-only fixed bottom-20 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
|
||||
>
|
||||
📦 Import bundle
|
||||
</button>
|
||||
|
||||
{/* Visual overlay when dragging */}
|
||||
{/* Visual overlay when dragging — was hardcoded blue-950/blue-400
|
||||
which doesn't flip with theme. accent colors stay visually
|
||||
consistent with the rest of the canvas in both modes. */}
|
||||
{isDragging && (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
|
||||
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-accent/15 backdrop-blur-sm border-2 border-dashed border-accent/40 pointer-events-none">
|
||||
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
||||
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
|
||||
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Importing spinner */}
|
||||
{/* Importing indicator — role=status + aria-live so SR users hear
|
||||
"Importing bundle..." while the API call is in flight, not just
|
||||
the result toast that fires after. motion-safe:animate-spin
|
||||
respects prefers-reduced-motion (Tailwind's motion-safe variant
|
||||
gates animation on the user's OS setting). */}
|
||||
{importing && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-zinc-900/95 border border-zinc-700/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3">
|
||||
<div className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-zinc-200">Importing bundle...</span>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-surface-sunken/95 border border-line/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full motion-safe:animate-spin"
|
||||
/>
|
||||
<span className="text-sm text-ink">Importing bundle...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { useTheme } from "@/lib/theme-provider";
|
||||
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
|
||||
import { WorkspaceNode } from "./WorkspaceNode";
|
||||
import { SidePanel } from "./SidePanel";
|
||||
@@ -69,6 +70,14 @@ export function Canvas() {
|
||||
}
|
||||
|
||||
function CanvasInner() {
|
||||
// ReactFlow's `colorMode` prop drives the styling of every viewport
|
||||
// primitive it renders directly (background dots, edge defaults,
|
||||
// selection rings, controls, minimap mask). Pre-fix this was hard-pinned
|
||||
// to "dark" — so on light theme the chrome (toolbar, side panel) flipped
|
||||
// to warm-paper but the canvas backplate + edges stayed black, leaving a
|
||||
// half-themed page. Pull resolvedTheme so the canvas matches the user's
|
||||
// selected mode (and the system preference when they pick "system").
|
||||
const { resolvedTheme } = useTheme();
|
||||
const rawNodes = useCanvasStore((s) => s.nodes);
|
||||
const edges = useCanvasStore((s) => s.edges);
|
||||
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
|
||||
@@ -244,13 +253,13 @@ function CanvasInner() {
|
||||
<>
|
||||
<a
|
||||
href="#canvas-main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-zinc-900 focus:text-zinc-100 focus:rounded-lg focus:border focus:border-zinc-700"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-surface-sunken focus:text-ink focus:rounded-lg focus:border focus:border-line"
|
||||
>
|
||||
Skip to canvas
|
||||
</a>
|
||||
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
|
||||
<main id="canvas-main" className="w-screen h-screen bg-surface">
|
||||
<ReactFlow
|
||||
colorMode="dark"
|
||||
colorMode={resolvedTheme}
|
||||
nodes={nodes}
|
||||
edges={allEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
@@ -273,15 +282,19 @@ function CanvasInner() {
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color="#27272a"
|
||||
// Match the line token so dots fade with the surface.
|
||||
// Hard-coded zinc-800 was invisible on warm-paper.
|
||||
color={resolvedTheme === "dark" ? "#27272a" : "#d4d0c4"}
|
||||
/>
|
||||
<Controls
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-surface-card [&>button]:!border-line/50 [&>button]:!text-ink-mid [&>button:hover]:!bg-surface-card [&>button:hover]:!text-ink"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
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)"}
|
||||
nodeColor={(node) => {
|
||||
// Parents show as a filled region — hierarchy visible at
|
||||
// a glance in the minimap without needing to zoom.
|
||||
|
||||
@@ -32,11 +32,18 @@ export function CommunicationOverlay() {
|
||||
|
||||
const fetchComms = useCallback(async () => {
|
||||
try {
|
||||
// Fetch activity from all online workspaces
|
||||
// Fan-out cap: each polled workspace = 1 round-trip. The platform
|
||||
// rate limits at 600 req/min/IP; combined with heartbeats + other
|
||||
// canvas polling, every workspace polled here costs ~6 req/min
|
||||
// (1 every 30s × 1 per workspace). Capping at 3 keeps this
|
||||
// overlay's footprint at 18 req/min worst case — well under
|
||||
// budget even with 8+ workspaces visible. Caught 2026-05-04 when
|
||||
// a user with 8+ workspaces (Design Director + 6 sub-agents +
|
||||
// 3 standalones) saw sustained 429s in canvas console.
|
||||
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
|
||||
const allComms: Communication[] = [];
|
||||
|
||||
for (const node of onlineNodes.slice(0, 6)) {
|
||||
for (const node of onlineNodes.slice(0, 3)) {
|
||||
try {
|
||||
const activities = await api.get<Array<{
|
||||
id: string;
|
||||
@@ -91,10 +98,20 @@ export function CommunicationOverlay() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Gate polling on visibility — when the user collapses the overlay
|
||||
// the data isn't being read, so the per-workspace fan-out becomes
|
||||
// pure rate-limit overhead. Pre-fix this overlay polled regardless
|
||||
// of whether the panel was shown, costing ~36 req/min from a
|
||||
// hidden surface.
|
||||
if (!visible) return;
|
||||
fetchComms();
|
||||
const interval = setInterval(fetchComms, 10000);
|
||||
// 30s cadence (was 10s). At 3-workspace fan-out that's 6 req/min
|
||||
// worst case from this overlay. Combined with heartbeats (~30/min)
|
||||
// and other canvas polling, leaves ample headroom under the 600/
|
||||
// min/IP server-side rate limit even at 8+ workspace tenants.
|
||||
const interval = setInterval(fetchComms, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchComms]);
|
||||
}, [fetchComms, visible]);
|
||||
|
||||
if (!visible || comms.length === 0) {
|
||||
return (
|
||||
@@ -102,7 +119,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-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
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>
|
||||
@@ -110,16 +127,16 @@ export function CommunicationOverlay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
|
||||
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-surface-sunken/95 border border-line/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line/60">
|
||||
<div className="text-[10px] font-semibold text-ink-mid uppercase tracking-wider">
|
||||
<span aria-hidden="true">↗↙ </span>Communications ({comms.length})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(false)}
|
||||
aria-label="Close communications panel"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
className="text-ink-soft hover:text-ink-mid text-xs"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
@@ -128,10 +145,10 @@ export function CommunicationOverlay() {
|
||||
<div className="overflow-y-auto max-h-[350px] p-2 space-y-1">
|
||||
{comms.map((c) => {
|
||||
const isSelected = selectedNodeId === c.sourceId || selectedNodeId === c.targetId;
|
||||
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-blue-400" : "text-amber-400";
|
||||
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-accent" : "text-warm";
|
||||
const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆";
|
||||
const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱";
|
||||
const statusColor = c.status === "ok" ? "text-emerald-400" : c.status === "error" ? "text-red-400" : "text-amber-400";
|
||||
const statusColor = c.status === "ok" ? "text-good" : c.status === "error" ? "text-bad" : "text-warm";
|
||||
const age = formatAge(c.timestamp);
|
||||
|
||||
return (
|
||||
@@ -140,31 +157,31 @@ export function CommunicationOverlay() {
|
||||
className={`rounded-lg px-2.5 py-1.5 text-[9px] border transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-950/30 border-blue-800/40"
|
||||
: "bg-zinc-800/30 border-zinc-700/20 hover:bg-zinc-800/50"
|
||||
: "bg-surface-card/30 border-line/20 hover:bg-surface-card/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={typeColor} aria-hidden="true">{typeIcon}</span>
|
||||
<span className="sr-only">{COMM_TYPE_LABELS[c.type] ?? c.type}</span>
|
||||
<span className="text-zinc-300 font-medium truncate">
|
||||
<span className="text-ink-mid font-medium truncate">
|
||||
{c.sourceName}
|
||||
</span>
|
||||
<span className="text-zinc-400" aria-hidden="true">→</span>
|
||||
<span className="text-ink-mid" aria-hidden="true">→</span>
|
||||
<span className="sr-only">to</span>
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
<span className="text-ink-mid truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
|
||||
<span className="sr-only">{c.status}</span>
|
||||
<span className="text-zinc-400">{age}</span>
|
||||
<span className="text-ink-mid">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.summary && (
|
||||
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
)}
|
||||
{c.durationMs && (
|
||||
<div className="text-zinc-400 pl-4">{c.durationMs}ms</div>
|
||||
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -91,12 +91,15 @@ export function ConfirmDialog({
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
// Hover goes DARKER, not lighter — lighter shades on white text drop
|
||||
// contrast below AA on the accent and red ramps. Darker hovers stay
|
||||
// readable in both light and dark themes.
|
||||
const confirmColors =
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-500 text-white"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-600 hover:bg-amber-500 text-white"
|
||||
: "bg-blue-600 hover:bg-blue-500 text-white";
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
|
||||
// Render via Portal so the fixed-position dialog escapes any containing block
|
||||
// (e.g. parents with transform, filter, will-change that break position:fixed).
|
||||
@@ -111,19 +114,19 @@ export function ConfirmDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-ink mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-ink-mid leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
{!singleButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
className="px-3.5 py-1.5 text-[13px] 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/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -131,7 +134,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -95,15 +95,15 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="console-modal-title"
|
||||
className="relative bg-zinc-950 border border-zinc-800 rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
|
||||
className="relative bg-surface border border-line rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
|
||||
<div>
|
||||
<h3 id="console-modal-title" className="text-sm font-semibold text-zinc-100">
|
||||
<h3 id="console-modal-title" className="text-sm font-semibold text-ink">
|
||||
EC2 console output
|
||||
</h3>
|
||||
{workspaceName && (
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5 truncate max-w-[600px]">
|
||||
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
|
||||
{workspaceName}
|
||||
</div>
|
||||
)}
|
||||
@@ -113,7 +113,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
|
||||
// 24x24 touch target (was ~10x16, well under WCAG 2.5.5).
|
||||
// Hover bg makes the area visible; focus-visible ring matches
|
||||
// the rest of the canvas chrome.
|
||||
className="w-6 h-6 inline-flex items-center justify-center rounded text-sm 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>
|
||||
@@ -121,14 +124,14 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
|
||||
<div className="flex-1 overflow-auto bg-black/80 p-4">
|
||||
{loading && (
|
||||
<div className="text-[12px] text-zinc-500" data-testid="console-loading">
|
||||
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
|
||||
Loading console output…
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
|
||||
className="text-[12px] text-warm bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
|
||||
data-testid="console-error"
|
||||
>
|
||||
{error}
|
||||
@@ -136,7 +139,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
{!loading && !error && output !== null && (
|
||||
<pre
|
||||
className="text-[11px] text-zinc-300 font-mono whitespace-pre-wrap break-all leading-tight"
|
||||
className="text-[11px] text-ink-mid font-mono whitespace-pre-wrap break-all leading-tight"
|
||||
data-testid="console-output"
|
||||
>
|
||||
{output || "(console output is empty — the instance may still be booting)"}
|
||||
@@ -144,18 +147,25 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-line bg-surface-sunken/40">
|
||||
{output && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output);
|
||||
// Add success feedback — without it, clicking Copy
|
||||
// looked like a no-op since the previous hover bg was
|
||||
// also a no-op (`hover:bg-surface-card` on top of the
|
||||
// same base). Toast confirms the write actually fired.
|
||||
navigator.clipboard
|
||||
.writeText(output)
|
||||
.then(() => showToast("Console output copied", "success"))
|
||||
.catch(() => showToast("Copy failed", "error"));
|
||||
} else {
|
||||
showToast("Copy requires HTTPS — please select and copy manually", "info");
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
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>
|
||||
@@ -163,7 +173,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
// Was hover:bg-surface-card (same as base — silent no-op).
|
||||
// Lift to surface-elevated so the button visibly responds,
|
||||
// matching the Cancel button in ConfirmDialog.
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -29,15 +29,38 @@ export function ContextMenu() {
|
||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
// Clamped position — (left, top) from contextMenu may overflow when the
|
||||
// user right-clicks near the right/bottom viewport edge. We measure the
|
||||
// rendered menu and shift it back inside on the same frame the cursor
|
||||
// opens it, so it never visibly clips. Falls back to the raw cursor
|
||||
// coords until the rAF runs.
|
||||
const [clamped, setClamped] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Auto-focus first enabled item when menu opens
|
||||
// Auto-focus first enabled item when menu opens, AND clamp position.
|
||||
// Both run together in a single rAF so we avoid two synchronous layout
|
||||
// reads + a paint between them.
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
requestAnimationFrame(() => {
|
||||
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
setClamped(null);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const first = node.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
first?.focus();
|
||||
// 8px viewport margin so the menu doesn't kiss the edge — matches
|
||||
// the floating-tooltip top-edge clamp in Tooltip.tsx.
|
||||
const margin = 8;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
|
||||
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
|
||||
if (x !== contextMenu.x || y !== contextMenu.y) setClamped({ x, y });
|
||||
});
|
||||
}, [contextMenu?.nodeId]);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [contextMenu?.nodeId, contextMenu?.x, contextMenu?.y]);
|
||||
|
||||
// Close on click outside or Escape
|
||||
useEffect(() => {
|
||||
@@ -287,24 +310,24 @@ export function ContextMenu() {
|
||||
role="menu"
|
||||
aria-label={`Actions for ${contextMenu.nodeData.name}`}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-[60] min-w-[200px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: clamped?.x ?? contextMenu.x, top: clamped?.y ?? contextMenu.y }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="px-3.5 py-2 border-b border-line/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-ink truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
|
||||
<span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
|
||||
return <div key={i} role="separator" className="h-px bg-surface-card/60 my-1" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
@@ -314,10 +337,10 @@ export function ContextMenu() {
|
||||
onClick={item.action}
|
||||
disabled={item.disabled}
|
||||
aria-disabled={item.disabled}
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent/50 disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
item.danger
|
||||
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
|
||||
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
|
||||
? "text-bad hover:bg-red-950/40 hover:text-bad"
|
||||
: "text-ink-mid hover:bg-surface-card/40 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
|
||||
|
||||
@@ -99,14 +99,14 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
aria-label="Conversation trace"
|
||||
>
|
||||
{/* Modal panel */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line">
|
||||
<div>
|
||||
<Dialog.Title className="text-sm font-semibold text-zinc-100">
|
||||
<Dialog.Title className="text-sm font-semibold text-ink">
|
||||
Conversation Trace
|
||||
</Dialog.Title>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
<p className="text-[10px] text-ink-soft mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation trace"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
className="text-ink-soft hover:text-ink-mid text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -124,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
<div className="text-xs text-ink-soft text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
<div className="text-xs text-ink-soft text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
@@ -160,28 +160,28 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
: isSend
|
||||
? "bg-cyan-500"
|
||||
: isReceive
|
||||
? "bg-blue-500"
|
||||
: "bg-zinc-600"
|
||||
? "bg-accent"
|
||||
: "bg-surface-card"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
|
||||
<div className="w-px flex-1 bg-surface-card min-h-[8px]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-zinc-400 font-mono">
|
||||
<span className="text-[9px] text-ink-mid font-mono">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
isError
|
||||
? "bg-red-950/50 text-red-400"
|
||||
? "bg-red-950/50 text-bad"
|
||||
: isSend
|
||||
? "bg-cyan-950/50 text-cyan-400"
|
||||
: isReceive
|
||||
? "bg-blue-950/50 text-blue-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
? "bg-blue-950/50 text-accent"
|
||||
: "bg-surface-card text-ink-mid"
|
||||
}`}
|
||||
>
|
||||
{isSend
|
||||
@@ -191,7 +191,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-zinc-400">
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
@@ -207,19 +207,19 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
<span className="text-zinc-400"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
<span className="text-ink-mid"> → </span>
|
||||
<span className="text-accent font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
<span className="text-accent font-medium">
|
||||
{targetName || wsName}
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-zinc-400">
|
||||
<span className="text-ink-mid">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
@@ -234,40 +234,40 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
|
||||
{/* Summary */}
|
||||
{entry.summary && !isA2A(entry) && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1">
|
||||
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
|
||||
<div className="text-[10px] text-ink-mid mt-1">
|
||||
<span className="text-ink-mid font-medium">{wsName}:</span>{" "}
|
||||
{entry.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[10px] text-red-400/80 mt-1 truncate">
|
||||
<div className="text-[10px] text-bad/80 mt-1 truncate">
|
||||
{entry.error_detail.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content — show request and/or response */}
|
||||
{requestText && (
|
||||
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-zinc-500 uppercase mb-1">
|
||||
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-ink-soft uppercase mb-1">
|
||||
{isSend ? "Task" : "Request"}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
|
||||
<span className="text-ink-mid"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{responseText && (
|
||||
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
|
||||
<span className="text-ink-mid"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,11 +281,11 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -98,26 +98,34 @@ export function CookieConsent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
// role="region" + aria-label, NOT role="dialog" + aria-modal. The
|
||||
// banner is informational — it never blocks the page, never traps
|
||||
// focus, and the user can keep using the canvas while it's up.
|
||||
// Claiming aria-modal="true" without a focus trap is genuinely
|
||||
// harmful for screen-reader users: they get told the rest of the
|
||||
// page is inert, jump into the banner, and then can't escape.
|
||||
// Region semantics let assistive tech navigate around it normally.
|
||||
// (Also: forcing a modal cookie banner would be a dark pattern —
|
||||
// GDPR explicitly discourages it.)
|
||||
<section
|
||||
role="region"
|
||||
aria-labelledby="cookie-consent-title"
|
||||
aria-describedby="cookie-consent-body"
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-line bg-surface/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-300">
|
||||
<p id="cookie-consent-title" className="font-medium text-zinc-100">
|
||||
<div className="text-sm text-ink-mid">
|
||||
<p id="cookie-consent-title" className="font-medium text-ink">
|
||||
Cookies & your privacy
|
||||
</p>
|
||||
<p id="cookie-consent-body" className="mt-1 text-zinc-400">
|
||||
<p id="cookie-consent-body" className="mt-1 text-ink-mid">
|
||||
We use strictly-necessary cookies for authentication and session
|
||||
continuity. Accept to also allow optional functional cookies that
|
||||
improve your canvas experience (layout preferences, recent
|
||||
workspaces). See our{" "}
|
||||
<a
|
||||
href="https://moleculesai.app/legal/privacy"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -130,20 +138,20 @@ export function CookieConsent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("rejected")}
|
||||
className="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
|
||||
className="rounded border border-line bg-surface-sunken px-4 py-2 text-sm text-ink hover:bg-surface-card focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Necessary only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("accepted")}
|
||||
className="rounded border border-blue-600 bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
||||
className="rounded border border-accent bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Accept all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ export function CreateWorkspaceButton() {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-accent hover:bg-accent-strong active:bg-accent text-sm font-medium rounded-xl text-white shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/30 transition-all duration-200 flex items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -333,12 +333,12 @@ export function CreateWorkspaceButton() {
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
|
||||
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface-sunken border border-line/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
|
||||
>
|
||||
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
|
||||
<Dialog.Title className="text-base font-semibold text-ink mb-1">
|
||||
Create Workspace
|
||||
</Dialog.Title>
|
||||
<p className="text-xs text-zinc-500 mb-5">
|
||||
<p className="text-xs text-ink-soft mb-5">
|
||||
Add a new workspace node to the canvas
|
||||
</p>
|
||||
|
||||
@@ -367,7 +367,7 @@ export function CreateWorkspaceButton() {
|
||||
{/* External toggle — when on, this workspace is BYO-compute:
|
||||
no template, no model, no hermes provider fields. Backend
|
||||
returns a copyable connection snippet via the modal. */}
|
||||
<label className="flex items-start gap-2 rounded-lg border border-zinc-800 p-3 cursor-pointer hover:border-zinc-700 transition-colors">
|
||||
<label className="flex items-start gap-2 rounded-lg border border-line p-3 cursor-pointer hover:border-line transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isExternal}
|
||||
@@ -375,8 +375,8 @@ export function CreateWorkspaceButton() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="text-xs">
|
||||
<div className="text-zinc-200 font-medium">External agent (bring your own compute)</div>
|
||||
<div className="text-zinc-500 mt-0.5">
|
||||
<div className="text-ink font-medium">External agent (bring your own compute)</div>
|
||||
<div className="text-ink-soft mt-0.5">
|
||||
Skip the container. We'll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,7 +398,7 @@ export function CreateWorkspaceButton() {
|
||||
aria-label="Workspace tier"
|
||||
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
|
||||
>
|
||||
<div className={`text-[11px] text-zinc-400 mb-1 ${isSaaS ? "" : "col-span-4"}`}>
|
||||
<div className={`text-[11px] text-ink-mid mb-1 ${isSaaS ? "" : "col-span-4"}`}>
|
||||
Tier{isSaaS ? " — dedicated VM" : ""}
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
@@ -413,8 +413,8 @@ export function CreateWorkspaceButton() {
|
||||
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
||||
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
@@ -429,13 +429,13 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="">None (root level)</option>
|
||||
{workspaces.map((ws) => (
|
||||
@@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
|
||||
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
|
||||
Hermes Provider
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-500 -mt-1">
|
||||
<p className="text-[11px] text-ink-soft -mt-1">
|
||||
Choose the AI provider and paste your API key. The key is
|
||||
stored as an encrypted workspace secret.
|
||||
</p>
|
||||
@@ -464,7 +464,7 @@ export function CreateWorkspaceButton() {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-provider-select"
|
||||
className="text-[11px] text-zinc-400 block mb-1"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
Provider
|
||||
</label>
|
||||
@@ -473,7 +473,7 @@ export function CreateWorkspaceButton() {
|
||||
value={hermesProvider}
|
||||
onChange={(e) => setHermesProvider(e.target.value)}
|
||||
aria-label="Hermes provider"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
|
||||
>
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
@@ -486,10 +486,10 @@ export function CreateWorkspaceButton() {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-api-key-input"
|
||||
className="text-[11px] text-zinc-400 block mb-1"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
API Key{" "}
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
@@ -502,17 +502,17 @@ export function CreateWorkspaceButton() {
|
||||
placeholder="sk-…"
|
||||
aria-label="Hermes API key"
|
||||
autoComplete="off"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-model-input"
|
||||
className="text-[11px] text-zinc-400 block mb-1"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
@@ -527,14 +527,14 @@ export function CreateWorkspaceButton() {
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="hermes-model-suggestions"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="hermes-model-suggestions">
|
||||
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
|
||||
(m) => <option key={m} value={m} />,
|
||||
)}
|
||||
</datalist>
|
||||
<p className="text-[10px] text-zinc-500 mt-1">
|
||||
<p className="text-[10px] text-ink-soft mt-1">
|
||||
Slug determines which provider hermes routes to at install time.
|
||||
</p>
|
||||
</div>
|
||||
@@ -544,7 +544,7 @@ export function CreateWorkspaceButton() {
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
|
||||
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -552,7 +552,7 @@ export function CreateWorkspaceButton() {
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button type="button" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
<button type="button" className="px-4 py-2 bg-surface-card hover:bg-surface-elevated hover:text-ink text-sm rounded-lg 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>
|
||||
</Dialog.Close>
|
||||
@@ -560,7 +560,7 @@ export function CreateWorkspaceButton() {
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
className="px-5 py-2 bg-accent hover:bg-accent-strong active:bg-accent text-sm rounded-lg 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"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
@@ -604,11 +604,11 @@ function InputField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={inputId} className="text-[11px] text-zinc-400 block mb-1">
|
||||
<label htmlFor={inputId} className="text-[11px] text-ink-mid block mb-1">
|
||||
{label}{" "}
|
||||
{required && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
@@ -623,10 +623,10 @@ function InputField({
|
||||
placeholder={placeholder}
|
||||
min={type === "number" ? "0" : undefined}
|
||||
step={type === "number" ? "0.01" : undefined}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
/>
|
||||
{helper && (
|
||||
<p className="mt-1 text-xs text-zinc-500">{helper}</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -89,10 +89,10 @@ export function DeleteCascadeConfirmDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cascade-dialog-title"
|
||||
className="relative bg-zinc-900 border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-surface-sunken border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-red-400">
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-bad">
|
||||
Delete Workspace and Children
|
||||
</h3>
|
||||
</div>
|
||||
@@ -101,20 +101,20 @@ export function DeleteCascadeConfirmDialog({
|
||||
{/* Warning */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-red-400" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-bad" aria-hidden="true">
|
||||
<path d="M8 3L14 13H2L8 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||
<path d="M8 7v3M8 11.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-zinc-300 leading-relaxed">
|
||||
<span className="font-medium text-red-300">"{name}"</span> has{" "}
|
||||
<strong className="text-zinc-100">{children.length}</strong> child{" "}
|
||||
<p className="text-[13px] text-ink-mid leading-relaxed">
|
||||
<span className="font-medium text-bad">"{name}"</span> has{" "}
|
||||
<strong className="text-ink">{children.length}</strong> child{" "}
|
||||
{children.length === 1 ? "workspace" : "workspaces"}:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Child list */}
|
||||
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-zinc-400 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-ink-mid max-h-32 overflow-y-auto">
|
||||
{children.map((c) => (
|
||||
<li key={c.id} className="truncate" title={c.name}>{c.name}</li>
|
||||
))}
|
||||
@@ -122,30 +122,37 @@ export function DeleteCascadeConfirmDialog({
|
||||
|
||||
{/* Cascade warning */}
|
||||
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
|
||||
<p className="text-[12px] text-red-300/80 leading-relaxed">
|
||||
<p className="text-[12px] text-bad/80 leading-relaxed">
|
||||
Deleting will cascade — <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkbox guard */}
|
||||
{/* Checkbox guard. Ring-offset color was zinc-900 — the dialog
|
||||
actually sits on bg-surface-sunken, so the offset showed
|
||||
the wrong color through the ring gap. Switched to the
|
||||
real bg + a danger-tinted ring. */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-red-500 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
|
||||
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
/>
|
||||
<span className="text-[12px] text-zinc-400 group-hover:text-zinc-300 leading-relaxed">
|
||||
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
|
||||
I understand this will permanently delete all listed workspaces and their data
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
// Was hover:bg-surface-card (same as base — silent no-op).
|
||||
// Lift to surface-elevated to match the Cancel pattern in
|
||||
// ConfirmDialog. Added focus-visible ring so keyboard users
|
||||
// see where focus lands.
|
||||
className="px-3.5 py-1.5 text-[13px] 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/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -153,10 +160,13 @@ export function DeleteCascadeConfirmDialog({
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
// Hover goes DARKER, not lighter — bg-red-500 on white text
|
||||
// drops contrast below AA vs bg-red-700. Same trap fixed in
|
||||
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
|
||||
${checked
|
||||
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-red-500/40 cursor-not-allowed"
|
||||
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Delete All
|
||||
|
||||
@@ -75,11 +75,11 @@ export function EmptyState() {
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-[1] overflow-y-auto py-8">
|
||||
<div className="relative max-w-2xl w-full rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
|
||||
<div className="relative max-w-2xl w-full rounded-3xl border border-line/70 bg-surface/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
|
||||
<div className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-blue-500/20 flex items-center justify-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-accent/20 flex items-center justify-center">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="3" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
<rect x="15" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
@@ -91,16 +91,16 @@ export function EmptyState() {
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-sky-400/80 mb-2">
|
||||
Welcome to Molecule AI
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-zinc-100 mb-1">
|
||||
<h2 className="text-xl font-semibold text-ink mb-1">
|
||||
Deploy your first agent
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400 mb-6 leading-relaxed">
|
||||
<p className="text-sm text-ink-mid mb-6 leading-relaxed">
|
||||
Pick a template to get started instantly, or create a blank workspace.
|
||||
</p>
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-400 py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<Spinner />
|
||||
Loading templates...
|
||||
</div>
|
||||
@@ -114,21 +114,21 @@ export function EmptyState() {
|
||||
key={t.id}
|
||||
onClick={() => void deploy(t)}
|
||||
disabled={anyDeploying}
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
className="group rounded-xl border border-line/60 bg-surface-sunken/50 px-3.5 py-3 hover:border-accent/40 hover:bg-surface-sunken/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-line/60 disabled:hover:bg-surface-sunken/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
<span className="text-sm font-medium text-ink group-hover:text-ink truncate">
|
||||
{deploying === t.id ? "Deploying..." : t.name}
|
||||
</span>
|
||||
<span className={`text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md border ${tierColor}`}>
|
||||
T{t.tier}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 line-clamp-2 leading-relaxed">
|
||||
<p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
|
||||
{t.description || "No description"}
|
||||
</p>
|
||||
{t.skill_count > 0 && (
|
||||
<p className="text-[9px] text-zinc-500 mt-1.5">
|
||||
<p className="text-[9px] text-ink-soft mt-1.5">
|
||||
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
|
||||
{t.model ? ` · ${t.model}` : ""}
|
||||
</p>
|
||||
@@ -144,18 +144,18 @@ export function EmptyState() {
|
||||
type="button"
|
||||
onClick={createBlank}
|
||||
disabled={anyDeploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
className="w-full rounded-xl border border-dashed border-line/60 bg-surface-sunken/30 px-4 py-3 text-sm text-ink-mid hover:text-ink hover:border-line hover:bg-surface-sunken/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-ink-mid disabled:hover:border-line/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
>
|
||||
{blankCreating ? "Creating..." : "+ Create blank workspace"}
|
||||
</button>
|
||||
|
||||
{/* Org templates — instantiate a whole team in one click */}
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800/50 text-left">
|
||||
<div className="mt-4 pt-4 border-t border-line/50 text-left">
|
||||
<OrgTemplatesSection />
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad">
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
@@ -166,13 +166,13 @@ export function EmptyState() {
|
||||
{modal}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-5 pt-4 border-t border-zinc-800/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-400">
|
||||
<div className="mt-5 pt-4 border-t border-line/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
|
||||
<span>Drag to nest workspaces into teams</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span className="text-ink-soft">|</span>
|
||||
<span>Right-click for actions</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span>Press <kbd className="px-1 py-0.5 bg-zinc-800 rounded text-zinc-500 font-mono">⌘K</kbd> to search</span>
|
||||
<span className="text-ink-soft">|</span>
|
||||
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">⌘K</kbd> to search</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export class ErrorBoundary extends React.Component<
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950 z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-zinc-900/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
|
||||
<svg
|
||||
width="24"
|
||||
@@ -70,20 +70,20 @@ export class ErrorBoundary extends React.Component<
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-2">
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400 mb-1">
|
||||
<p className="text-sm text-ink-mid mb-1">
|
||||
An unexpected error occurred while rendering the application.
|
||||
</p>
|
||||
<p className="text-xs text-red-400/80 mb-6 font-mono break-all">
|
||||
<p className="text-xs text-bad/80 mb-6 font-mono break-all">
|
||||
{this.state.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
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-zinc-700 hover:border-zinc-600 px-5 py-2 text-sm font-medium text-zinc-300 hover:text-zinc-100 transition-colors"
|
||||
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>
|
||||
|
||||
@@ -18,6 +18,157 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
// Per-tab help metadata: docs link, where-to-install link, common errors.
|
||||
// All URLs verified against repo content (docs/guides/* file paths map to
|
||||
// docs.molecule.ai/docs/guides/*; canonical hostname confirmed by existing
|
||||
// blog post canonical metadata) or against the snippet text the operator
|
||||
// just copied. Never linking to a URL that wasn't already in product —
|
||||
// dead links here defeat the purpose of "more comprehensive instructions."
|
||||
const TAB_HELP: Record<
|
||||
Tab,
|
||||
{
|
||||
docsUrl?: string;
|
||||
docsLabel?: string;
|
||||
downloadUrl?: string;
|
||||
downloadLabel?: string;
|
||||
commonIssues?: { symptom: string; check: string }[];
|
||||
}
|
||||
> = {
|
||||
mcp: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
|
||||
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Tools not appearing in your agent",
|
||||
check:
|
||||
"Run `claude mcp list` (or your runtime's equivalent) — the molecule entry should be listed. If missing, re-run the `claude mcp add` line.",
|
||||
},
|
||||
{
|
||||
symptom: "ConnectionRefused / DNS error on first call",
|
||||
check:
|
||||
"PLATFORM_URL must include the scheme (https://) and have no trailing slash. Verify with `curl $PLATFORM_URL/healthz`.",
|
||||
},
|
||||
],
|
||||
},
|
||||
python: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
|
||||
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "401 from /heartbeat",
|
||||
check:
|
||||
"AUTH_TOKEN expired or wrong workspace_id. Tokens are shown only once at create time — re-create the workspace to get a fresh token.",
|
||||
},
|
||||
{
|
||||
symptom: "AGENT_URL not reachable from platform",
|
||||
check:
|
||||
"Public HTTPS URL required for inbound A2A. Use ngrok or Cloudflare Tunnel if your agent is behind NAT.",
|
||||
},
|
||||
],
|
||||
},
|
||||
claude: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://claude.com/claude-code",
|
||||
downloadLabel: "Claude Code (claude.com)",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "plugin not installed",
|
||||
check:
|
||||
"Run `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` then `/plugin install molecule@molecule-mcp-claude-channel` inside Claude Code, then `/reload-plugins`.",
|
||||
},
|
||||
{
|
||||
symptom: "not on the approved channels allowlist",
|
||||
check:
|
||||
"Custom channels need `--dangerously-load-development-channels` on the launch command. Team/Enterprise orgs need admin to set `channelsEnabled` + `allowedChannelPlugins` in claude.ai admin settings.",
|
||||
},
|
||||
{
|
||||
symptom: "Inbound messages not arriving",
|
||||
check:
|
||||
"Check stderr for `molecule channel: connected — watching N workspace(s)`. Verify ~/.claude/channels/molecule/.env has the right PLATFORM_URL + token.",
|
||||
},
|
||||
],
|
||||
},
|
||||
hermes: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://github.com/NousResearch/hermes-agent",
|
||||
downloadLabel: "hermes-agent (NousResearch)",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Gateway start failure",
|
||||
check:
|
||||
"Tail ~/.hermes/gateway.log. YAML duplicate-key in config.yaml is the most common cause — `gateway:` block must appear exactly once.",
|
||||
},
|
||||
{
|
||||
symptom: "Plugin not discovered after install",
|
||||
check:
|
||||
"Run `pip show hermes-channel-molecule` to confirm install. Some hermes builds need `hermes plugin reload` before the new platform_plugins entry takes effect.",
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
downloadUrl: "https://github.com/openai/codex",
|
||||
downloadLabel: "openai/codex",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "[mcp_servers.molecule] not loaded",
|
||||
check:
|
||||
"Codex must be ≥ 0.57. Check with `codex --version`; upgrade via `npm install -g @openai/codex@latest`.",
|
||||
},
|
||||
{
|
||||
symptom: "TOML parse error after re-running setup",
|
||||
check:
|
||||
"TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.",
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Gateway not starting",
|
||||
check:
|
||||
"Tail ~/.openclaw/gateway.log. The loopback bind requires :18789 to be free — check with `lsof -iTCP:18789`.",
|
||||
},
|
||||
{
|
||||
symptom: "openclaw mcp set rejected",
|
||||
check:
|
||||
"The heredoc generates JSON; verify it parsed by running `jq < ~/.openclaw/mcp/molecule.json`. Re-run `openclaw mcp set` if the file is malformed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
curl: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "401 / 403 on register",
|
||||
check:
|
||||
"WORKSPACE_AUTH_TOKEN must be the value shown at workspace create. Tokens are shown only once.",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
},
|
||||
};
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
workspace_id: string;
|
||||
platform_url: string;
|
||||
@@ -40,6 +191,22 @@ export interface ExternalConnectionInfo {
|
||||
// + inbound. Optional for backward compat with platforms that
|
||||
// haven't shipped PR #2413 yet.
|
||||
universal_mcp_snippet?: string;
|
||||
// Hermes channel snippet — for operators whose external agent IS a
|
||||
// hermes-agent session. Routes A2A traffic into the hermes gateway
|
||||
// via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule).
|
||||
// Long-poll based (no tunnel) — same UX shape as the Claude Code
|
||||
// channel tab. Gives hermes true push parity. Optional for backward
|
||||
// compat with platforms that haven't shipped this PR yet.
|
||||
hermes_channel_snippet?: string;
|
||||
// Codex MCP config snippet — wires the molecule MCP server into
|
||||
// ~/.codex/config.toml so codex agents can call platform tools.
|
||||
// Outbound-tools-only today (codex's MCP client doesn't route
|
||||
// notifications/*); push parity would need a separate bridge daemon.
|
||||
codex_snippet?: string;
|
||||
// OpenClaw MCP config snippet — wires molecule MCP + starts the
|
||||
// openclaw gateway on loopback. Outbound-tools-only today; push
|
||||
// parity on an external openclaw needs a sessions.steer bridge.
|
||||
openclaw_snippet?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -47,13 +214,19 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "fields";
|
||||
|
||||
export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// Default to Claude Code when the platform offers it — that's the
|
||||
// newest + simplest path (no tunnel needed). Falls back to Python
|
||||
// for older platform builds that don't ship the snippet.
|
||||
const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python";
|
||||
// Default to Universal MCP when the platform offers it — runtime-
|
||||
// agnostic outbound tool path that works for any MCP-aware runtime
|
||||
// (Claude Code, hermes, codex, etc.) and lets operators inspect the
|
||||
// primitives before picking a runtime-specific tab. Python SDK is
|
||||
// the fallback for platforms predating the universal_mcp_snippet
|
||||
// field. Pre-2026-05-03 the default was "claude" (Claude Code first)
|
||||
// but operators using non-Claude runtimes opened to a tab they had
|
||||
// to skip past — universal MCP works for everyone as a starting
|
||||
// point and the runtime-specific tabs are still one click away.
|
||||
const initialTab: Tab = info?.universal_mcp_snippet
|
||||
? "mcp"
|
||||
: "python";
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
@@ -108,18 +281,36 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
|
||||
// name as Universal MCP). Stamp the auth_token in so the operator's
|
||||
// copy-paste is fully ready-to-run.
|
||||
const filledHermes = info.hermes_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Codex + OpenClaw snippets carry the placeholder inside the
|
||||
// generated config block (TOML / JSON respectively). Stamp the
|
||||
// token in so the copy-paste is one less manual edit.
|
||||
const filledCodex = info.codex_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledOpenClaw = info.openclaw_snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-zinc-900 border border-zinc-700 p-6 shadow-2xl">
|
||||
<Dialog.Title className="text-lg font-semibold text-white">
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-surface-sunken border border-line p-6 shadow-2xl">
|
||||
<Dialog.Title className="text-lg font-semibold text-ink">
|
||||
Connect your external agent
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-sm text-zinc-400">
|
||||
<Dialog.Description className="mt-1 text-sm text-ink-mid">
|
||||
Paste the snippet below into your agent's deployment. The
|
||||
auth token is shown <span className="text-amber-400">only once</span>
|
||||
auth token is shown <span className="text-warm">only once</span>
|
||||
{" "}— save it somewhere safe before closing this dialog.
|
||||
</Dialog.Description>
|
||||
|
||||
@@ -127,7 +318,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-zinc-800"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
@@ -135,10 +326,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledChannel) tabs.push("claude");
|
||||
tabs.push("python");
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
@@ -150,12 +349,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
tab === t
|
||||
? "border-blue-500 text-white"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
? "border-accent text-ink"
|
||||
: "border-transparent text-ink-soft hover:text-ink-mid"
|
||||
}`}
|
||||
>
|
||||
{t === "claude"
|
||||
? "Claude Code"
|
||||
: t === "hermes"
|
||||
? "Hermes"
|
||||
: t === "codex"
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
@@ -205,6 +410,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
{tab === "hermes" && filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
{tab === "codex" && filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
{tab === "openclaw" && filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
@@ -220,13 +452,14 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
|
||||
</div>
|
||||
)}
|
||||
<HelpBlock help={TAB_HELP[tab]} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200"
|
||||
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
|
||||
>
|
||||
I've saved it — close
|
||||
</button>
|
||||
@@ -252,22 +485,86 @@ function SnippetBlock({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className="text-xs text-ink-soft">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600/80 hover:bg-blue-500 text-white"
|
||||
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-zinc-950 border border-zinc-800 rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-zinc-200">
|
||||
<pre className="text-xs bg-surface border border-line rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-ink">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// HelpBlock — collapsible "Need help?" section under each tab's snippet.
|
||||
// Renders only the keys present in the per-tab help metadata (no empty
|
||||
// sections). Closed by default so the snippet stays the visual focus;
|
||||
// operators with a working setup never see this. Uses native <details>
|
||||
// for keyboard accessibility (Tab + Enter) without extra ARIA wiring.
|
||||
function HelpBlock({
|
||||
help,
|
||||
}: {
|
||||
help: (typeof TAB_HELP)[Tab] | undefined;
|
||||
}) {
|
||||
if (!help) return null;
|
||||
const { docsUrl, docsLabel, downloadUrl, downloadLabel, commonIssues } = help;
|
||||
if (!docsUrl && !downloadUrl && !commonIssues?.length) return null;
|
||||
|
||||
return (
|
||||
<details className="mt-3 border border-line rounded-lg bg-surface text-xs">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-ink-mid hover:text-ink">
|
||||
Need help? — install link, docs, common errors
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 space-y-2">
|
||||
{downloadUrl && (
|
||||
<div>
|
||||
<span className="text-ink-soft">Where to install: </span>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:text-accent-strong"
|
||||
>
|
||||
{downloadLabel || downloadUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{docsUrl && (
|
||||
<div>
|
||||
<span className="text-ink-soft">Documentation: </span>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:text-accent-strong"
|
||||
>
|
||||
{docsLabel || docsUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{commonIssues && commonIssues.length > 0 && (
|
||||
<div>
|
||||
<div className="text-ink-soft mb-1">Common errors:</div>
|
||||
<ul className="space-y-1.5 pl-3">
|
||||
{commonIssues.map((issue, i) => (
|
||||
<li key={i}>
|
||||
<code className="text-warm font-mono">{issue.symptom}</code>
|
||||
<span className="text-ink-mid"> — {issue.check}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
@@ -283,9 +580,9 @@ function Field({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500 w-36 shrink-0">{label}</span>
|
||||
<span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
|
||||
<code
|
||||
className={`flex-1 text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-zinc-200 break-all ${mono ? "font-mono" : ""}`}
|
||||
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{value || "(missing)"}
|
||||
</code>
|
||||
@@ -293,7 +590,7 @@ function Field({
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
disabled={!value}
|
||||
className="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 disabled:opacity-40"
|
||||
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { STATUS_CONFIG } from "@/lib/design-tokens";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const;
|
||||
|
||||
// Tier descriptions kept in sync with CreateWorkspaceDialog.tsx (the
|
||||
// source of truth for what each tier means semantically). Colors come
|
||||
// from TIER_CONFIG so the legend swatch matches the badge actually
|
||||
// rendered on every WorkspaceNode — drift here misled users into
|
||||
// thinking the legend documented a different tier than the one shown.
|
||||
const LEGEND_TIERS: ReadonlyArray<{ tier: number; label: string }> = [
|
||||
{ tier: 1, label: "Sandboxed" },
|
||||
{ tier: 2, label: "Standard" },
|
||||
{ tier: 3, label: "Privileged" },
|
||||
{ tier: 4, label: "Full Access" },
|
||||
];
|
||||
|
||||
// Persist the user's choice across sessions. Default is "open" so
|
||||
// first-time users still see the symbol key; once dismissed we
|
||||
// respect that until they explicitly reopen via the floating pill.
|
||||
@@ -65,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-zinc-900/95 border border-zinc-700/50 px-3 py-1.5 text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-zinc-200 hover:border-zinc-600 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
|
||||
@@ -74,15 +86,18 @@ export function Legend() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 ${leftClass} z-30 bg-zinc-900/95 border border-zinc-700/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-zinc-400 uppercase tracking-wider">Legend</div>
|
||||
<div className="text-[11px] font-semibold text-ink-mid uppercase tracking-wider">Legend</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeLegend}
|
||||
aria-label="Hide legend"
|
||||
title="Hide legend"
|
||||
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
// 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-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -90,7 +105,7 @@ export function Legend() {
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Status</div>
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{LEGEND_STATUSES.map((s) => (
|
||||
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
|
||||
@@ -100,22 +115,22 @@ export function Legend() {
|
||||
|
||||
{/* Tiers */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Tier</div>
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<TierItem tier={1} label="Sandboxed" color="text-sky-300 bg-sky-950/40 border-sky-700/30" />
|
||||
<TierItem tier={2} label="Standard" color="text-violet-300 bg-violet-950/40 border-violet-700/30" />
|
||||
<TierItem tier={3} label="Full Access" color="text-amber-300 bg-amber-950/40 border-amber-700/30" />
|
||||
{LEGEND_TIERS.map(({ tier, label }) => (
|
||||
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Communication</div>
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
|
||||
<CommItem icon="↙" color="text-blue-400" label="A2A In" />
|
||||
<CommItem icon="◆" color="text-amber-400" label="Task" />
|
||||
<CommItem icon="!" color="text-red-400" label="Error" />
|
||||
<CommItem icon="↙" color="text-accent" label="A2A In" />
|
||||
<CommItem icon="◆" color="text-warm" label="Task" />
|
||||
<CommItem icon="!" color="text-bad" label="Error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +141,7 @@ function StatusItem({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +150,7 @@ function TierItem({ tier, label, color }: { tier: number; label: string; color:
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] font-mono px-1 py-0.5 rounded border ${color}`}>T{tier}</span>
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +159,7 @@ function CommItem({ icon, color, label }: { icon: string; color: string; label:
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] ${color}`}>{icon}</span>
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ function MemorySkeletonRows() {
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-3 animate-pulse"
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-3 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 rounded bg-zinc-700/50 flex-1" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-8" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-6" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-10" />
|
||||
<div className="h-2 rounded bg-surface-card/50 flex-1" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-8" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-6" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -148,7 +148,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
if (loading && entries.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-zinc-500">Loading memories…</span>
|
||||
<span className="text-xs text-ink-soft">Loading memories…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scope tabs */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
|
||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
@@ -167,8 +167,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
activeScope === scope
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-card text-ink-mid hover:bg-surface-card hover:text-ink",
|
||||
].join(" ")}
|
||||
>
|
||||
{scope}
|
||||
@@ -178,7 +178,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Search bar + namespace filter */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
|
||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
|
||||
<div className="relative flex items-center">
|
||||
{/* Magnifying glass icon */}
|
||||
<svg
|
||||
@@ -186,7 +186,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
className="absolute left-2.5 text-zinc-500 pointer-events-none shrink-0"
|
||||
className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
@@ -198,7 +198,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search…"
|
||||
aria-label="Search memories"
|
||||
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
className="w-full bg-surface-sunken border border-line/60 focus:border-accent/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -208,7 +208,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 text-zinc-500 hover:text-zinc-200 transition-colors text-sm leading-none"
|
||||
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -217,7 +217,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
|
||||
{/* Namespace filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
|
||||
<label htmlFor="namespace-filter" className="text-[10px] text-ink-soft shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<input
|
||||
@@ -227,14 +227,14 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
placeholder="all namespaces"
|
||||
aria-label="Filter by namespace"
|
||||
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
|
||||
className="flex-1 bg-surface-sunken border border-line/60 focus:border-accent/60 rounded px-2 py-1 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center justify-between shrink-0">
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
|
||||
<span className="text-[11px] text-ink-soft">
|
||||
{debouncedQuery
|
||||
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
|
||||
: entries.length === 1
|
||||
@@ -244,7 +244,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
@@ -256,7 +256,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -269,11 +269,11 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
) : entries.length === 0 ? (
|
||||
debouncedQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-ink-mid">
|
||||
No memories match your search
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
Try a different query or{" "}
|
||||
<button
|
||||
type="button"
|
||||
@@ -281,7 +281,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
className="text-blue-500 hover:text-blue-400 underline transition-colors"
|
||||
className="text-accent hover:text-accent underline transition-colors"
|
||||
>
|
||||
clear the search
|
||||
</button>
|
||||
@@ -290,9 +290,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No {activeScope} memories</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
{activeScope === "LOCAL"
|
||||
? "This workspace has not written any local memories yet."
|
||||
: activeScope === "TEAM"
|
||||
@@ -340,11 +340,11 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
<div className="rounded-lg border border-line/60 bg-surface-sunken/50 overflow-hidden">
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
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}
|
||||
@@ -354,9 +354,9 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
|
||||
entry.scope === "LOCAL"
|
||||
? "bg-zinc-700 text-zinc-400"
|
||||
? "bg-surface-card text-ink-mid"
|
||||
: entry.scope === "TEAM"
|
||||
? "bg-blue-950 text-blue-400"
|
||||
? "bg-blue-950 text-accent"
|
||||
: "bg-violet-950 text-violet-400",
|
||||
].join(" ")}
|
||||
title={`Scope: ${entry.scope}`}
|
||||
@@ -365,12 +365,12 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
</span>
|
||||
|
||||
{/* Namespace tag */}
|
||||
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
|
||||
<span className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[80px]" title={entry.namespace}>
|
||||
{entry.namespace}
|
||||
</span>
|
||||
|
||||
{/* Content preview */}
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-ink-mid truncate text-left">
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
|
||||
</span>
|
||||
|
||||
@@ -380,8 +380,8 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono tabular-nums",
|
||||
entry.similarity_score >= 0.8
|
||||
? "text-blue-500"
|
||||
: "text-zinc-400",
|
||||
? "text-accent"
|
||||
: "text-ink-mid",
|
||||
].join(" ")}
|
||||
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
|
||||
data-testid="similarity-badge"
|
||||
@@ -390,10 +390,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[9px] text-zinc-600 shrink-0">
|
||||
<span className="text-[9px] text-ink-soft shrink-0">
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
|
||||
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
@@ -404,13 +404,13 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
id={bodyId}
|
||||
role="region"
|
||||
aria-label="Memory details"
|
||||
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
|
||||
className="border-t border-line/50 px-3 pb-3 pt-2 space-y-2"
|
||||
>
|
||||
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
<pre className="text-[10px] font-mono text-ink-mid bg-surface rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
{entry.content}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
@@ -420,7 +420,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Delete 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-red-400 transition-colors shrink-0"
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight";
|
||||
import {
|
||||
getKeyLabel,
|
||||
type ModelSpec,
|
||||
type ProviderChoice,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
buildProviderCatalog,
|
||||
findProviderForModel,
|
||||
type SelectorValue,
|
||||
} from "./ProviderModelSelector";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -38,6 +48,14 @@ interface Props {
|
||||
* the API-key fields. The picker passes the entered slug back via
|
||||
* onKeysAdded. */
|
||||
modelSuggestions?: string[];
|
||||
/** Full model specs from the template (with required_env per model).
|
||||
* When provided, the picker auto-snaps the provider radio to the
|
||||
* matching provider as the user changes the model — fixes the
|
||||
* "type MiniMax model, see ANTHROPIC_API_KEY field" cascade bug
|
||||
* (sibling of the ConfigTab cascade fix in #2516). Optional so
|
||||
* callers without model→provider mapping data can still use the
|
||||
* picker as-is. */
|
||||
models?: ModelSpec[];
|
||||
/** Pre-fill the model input. */
|
||||
initialModel?: string;
|
||||
/** Override the modal's title + description copy. The default
|
||||
@@ -83,6 +101,7 @@ export function MissingKeysModal({
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
@@ -102,6 +121,7 @@ export function MissingKeysModal({
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
modelSuggestions={modelSuggestions}
|
||||
models={models}
|
||||
initialModel={initialModel}
|
||||
title={title}
|
||||
description={description}
|
||||
@@ -131,6 +151,22 @@ export function MissingKeysModal({
|
||||
// Provider-picker mode — choose one option, save its env var(s), deploy.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Provider id derived from a model spec — sorted+joined required_env,
|
||||
* matching the formula in providersFromTemplate(). When the model has
|
||||
* no required_env (local/self-hosted endpoints) returns null, since
|
||||
* there's no provider option the radio could snap to. Exported for
|
||||
* the cascade-snap test. */
|
||||
export function providerIdForModel(
|
||||
modelId: string,
|
||||
models: ModelSpec[] | undefined,
|
||||
): string | null {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed || !models) return null;
|
||||
const m = models.find((x) => x.id === trimmed);
|
||||
if (!m?.required_env || m.required_env.length === 0) return null;
|
||||
return [...m.required_env].sort().join("|");
|
||||
}
|
||||
|
||||
function ProviderPickerModal({
|
||||
open,
|
||||
providers,
|
||||
@@ -141,6 +177,7 @@ function ProviderPickerModal({
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
@@ -154,45 +191,87 @@ function ProviderPickerModal({
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
models?: ModelSpec[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
// Prefer the first provider whose env vars are already satisfied by
|
||||
// the configured set — pre-selecting "the option the user already has
|
||||
// keys for" matches expected UX. Falls back to providers[0] otherwise.
|
||||
const initialSelected = useMemo(() => {
|
||||
// Single model source: `models` from caller when present, else
|
||||
// synthesize a stub list from the legacy `providers` shape so older
|
||||
// callers (pre-PR-2534) still drive the picker. ProviderModelSelector
|
||||
// and findProviderForModel BOTH consume this list — passing the same
|
||||
// shape to both keeps ids identical, so back-derivation matches the
|
||||
// dropdown's option values.
|
||||
const selectorModels = useMemo(() => {
|
||||
if (models && models.length > 0) return models;
|
||||
return providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.label,
|
||||
required_env: p.envVars,
|
||||
}));
|
||||
}, [models, providers]);
|
||||
|
||||
const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]);
|
||||
|
||||
// Initial selector value: prefer back-derivation from initialModel
|
||||
// (template-deploy passes the template default), then the first
|
||||
// provider already satisfied by configuredKeys, then catalog[0].
|
||||
const initial = useMemo<SelectorValue>(() => {
|
||||
if (initialModel) {
|
||||
const matched = findProviderForModel(catalog, initialModel);
|
||||
if (matched) {
|
||||
return {
|
||||
providerId: matched.id,
|
||||
model: initialModel,
|
||||
envVars: matched.envVars,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (configuredKeys) {
|
||||
const satisfied = providers.find((p) =>
|
||||
const satisfied = catalog.find((p) =>
|
||||
p.envVars.every((k) => configuredKeys.has(k)),
|
||||
);
|
||||
if (satisfied) return satisfied.id;
|
||||
if (satisfied) {
|
||||
return {
|
||||
providerId: satisfied.id,
|
||||
model: satisfied.wildcard ? "" : satisfied.models[0]?.id ?? "",
|
||||
envVars: satisfied.envVars,
|
||||
};
|
||||
}
|
||||
}
|
||||
return providers[0].id;
|
||||
}, [providers, configuredKeys]);
|
||||
const first = catalog[0];
|
||||
if (!first) return { providerId: "", model: "", envVars: [] };
|
||||
return {
|
||||
providerId: first.id,
|
||||
model: first.wildcard ? "" : first.models[0]?.id ?? "",
|
||||
envVars: first.envVars,
|
||||
};
|
||||
}, [catalog, initialModel, configuredKeys]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(initialSelected);
|
||||
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [model, setModel] = useState(initialModel ?? "");
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Legacy compat: map the selector value back into the old `selected`/
|
||||
// `model` shape for the rest of the modal body (footer copy, etc.).
|
||||
const selected = useMemo(
|
||||
() => providers.find((p) => p.id === selectedId) ?? providers[0],
|
||||
[providers, selectedId],
|
||||
() =>
|
||||
providers.find((p) => p.id === selectorValue.providerId) ??
|
||||
providers[0],
|
||||
[providers, selectorValue.providerId],
|
||||
);
|
||||
|
||||
const showModelInput = (modelSuggestions?.length ?? 0) > 0 || initialModel !== undefined;
|
||||
const model = selectorValue.model;
|
||||
const showModelInput = catalog.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId(initialSelected);
|
||||
setModel(initialModel ?? "");
|
||||
}, [open, initialSelected, initialModel]);
|
||||
setSelectorValue(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries(
|
||||
selected.envVars.map((key) => ({
|
||||
selectorValue.envVars.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
// Pre-mark as saved when the key is already in the configured
|
||||
@@ -203,13 +282,13 @@ function ProviderPickerModal({
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selected, configuredKeys]);
|
||||
}, [open, selectorValue.envVars, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, selectedId]);
|
||||
}, [open, selectorValue.providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -289,9 +368,9 @@ function ProviderPickerModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
|
||||
@@ -303,14 +382,14 @@ function ProviderPickerModal({
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
{title ?? "Missing API Keys"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
{description ?? (
|
||||
<>
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime supports multiple providers. Pick one and paste its API key.
|
||||
</>
|
||||
)}
|
||||
@@ -318,89 +397,34 @@ function ProviderPickerModal({
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{showModelInput && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-picker-model-input"
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-red-400">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider-picker-model-input"
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="e.g. minimax/MiniMax-M2.7"
|
||||
aria-label="Model slug"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="provider-picker-model-suggestions"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
/>
|
||||
<datalist id="provider-picker-model-suggestions">
|
||||
{modelSuggestions?.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
<p className="text-[9px] text-zinc-500 mt-1 leading-relaxed">
|
||||
Slug determines provider routing at install time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
|
||||
Provider
|
||||
</legend>
|
||||
{providers.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
|
||||
selectedId === p.id
|
||||
? "bg-blue-600/15 border-blue-500/50"
|
||||
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={p.id}
|
||||
checked={selectedId === p.id}
|
||||
onChange={() => setSelectedId(p.id)}
|
||||
className="mt-0.5 accent-blue-500"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
|
||||
<div className="text-[10px] font-mono text-zinc-500">
|
||||
{p.envVars.join(", ")}
|
||||
</div>
|
||||
{p.note && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
{p.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{/* Shared provider→model selector. Source of truth for provider
|
||||
taxonomy + model filtering. Same component is used in
|
||||
ConfigTab so behavior + vendor split is identical across
|
||||
all 3 deploy surfaces (modal here, settings tab, template
|
||||
palette flow). */}
|
||||
<ProviderModelSelector
|
||||
models={selectorModels}
|
||||
value={selectorValue}
|
||||
onChange={setSelectorValue}
|
||||
variant="stack"
|
||||
idPrefix="provider-picker"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
|
||||
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-300 font-medium">
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
|
||||
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
@@ -422,12 +446,12 @@ function ProviderPickerModal({
|
||||
handleSaveKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
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>
|
||||
@@ -435,19 +459,19 @@ function ProviderPickerModal({
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
||||
<div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -456,7 +480,7 @@ function ProviderPickerModal({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
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>
|
||||
@@ -465,9 +489,10 @@ function ProviderPickerModal({
|
||||
disabled={
|
||||
!allSaved ||
|
||||
anySaving ||
|
||||
!selectorValue.providerId ||
|
||||
(showModelInput && model.trim() === "")
|
||||
}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
|
||||
</button>
|
||||
@@ -615,9 +640,9 @@ function AllKeysModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
|
||||
@@ -629,12 +654,12 @@ function AllKeysModal({
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
Missing API Keys
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</p>
|
||||
</div>
|
||||
@@ -643,17 +668,17 @@ function AllKeysModal({
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
|
||||
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-300 font-medium">
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
|
||||
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
@@ -675,37 +700,37 @@ function AllKeysModal({
|
||||
handleSaveKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>}
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{globalError && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-red-400">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -715,7 +740,7 @@ function AllKeysModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
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>
|
||||
@@ -723,7 +748,7 @@ function AllKeysModal({
|
||||
type="button"
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
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>
|
||||
|
||||
@@ -132,12 +132,14 @@ export function OnboardingWizard() {
|
||||
<div
|
||||
role="complementary"
|
||||
aria-label="Onboarding guide"
|
||||
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-line/60 bg-surface-sunken/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-zinc-800">
|
||||
{/* Progress bar — was hardcoded from-blue-500 to-sky-400, neither
|
||||
tone exists in warm-paper light theme. Switched to the accent
|
||||
ramp so the gradient reads as brand color in both themes. */}
|
||||
<div className="h-1 bg-surface-card">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-strong transition-all duration-500"
|
||||
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -155,24 +157,26 @@ export function OnboardingWizard() {
|
||||
<div className="p-4">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
|
||||
{/* text-sky-400/80 was hardcoded; flip to text-accent so the
|
||||
indicator stays brand-tinted in both themes. */}
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
Step {currentStepIdx + 1} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Skip onboarding guide"
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
Skip guide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-sm font-medium text-zinc-100 mb-1">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">
|
||||
{currentStep.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-zinc-400 leading-relaxed mb-3">
|
||||
<p className="text-[11px] text-ink-mid leading-relaxed mb-3">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
|
||||
@@ -181,7 +185,11 @@ export function OnboardingWizard() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
// Was bg-accent-strong/90 hover:bg-accent — accent is the
|
||||
// LIGHTER variant, so this hovered lighter on white text and
|
||||
// dropped contrast below AA. Same trap fixed in
|
||||
// ConfirmDialog/ApprovalBanner. Hover the OTHER direction.
|
||||
className="flex-1 px-3 py-1.5 bg-accent hover:bg-accent-strong rounded-lg text-[11px] font-medium text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
{step === "welcome"
|
||||
? "Create Workspace"
|
||||
@@ -199,7 +207,10 @@ export function OnboardingWizard() {
|
||||
if (next) setStep(next.id);
|
||||
else dismiss();
|
||||
}}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-[11px] text-zinc-400 transition-colors"
|
||||
// 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:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -240,14 +240,14 @@ export function OrgImportPreflightModal({
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
|
||||
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-surface-sunken border border-line shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 py-4 border-b border-zinc-800">
|
||||
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
|
||||
<header className="px-5 py-4 border-b border-line">
|
||||
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
|
||||
Deploy {orgName}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-zinc-500">
|
||||
<p className="mt-0.5 text-[11px] text-ink-soft">
|
||||
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
||||
Review the credentials needed before import.
|
||||
</p>
|
||||
@@ -283,23 +283,23 @@ export function OrgImportPreflightModal({
|
||||
/>
|
||||
)}
|
||||
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
|
||||
<p className="text-[12px] text-zinc-400">
|
||||
<p className="text-[12px] text-ink-mid">
|
||||
No additional credentials required for this template.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
||||
<footer className="px-5 py-3 border-t border-line flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
|
||||
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-elevated hover:text-ink 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 className="flex items-center gap-2">
|
||||
{missingRecommended.length > 0 && canProceed && (
|
||||
<span className="text-[10px] text-amber-400/90">
|
||||
<span className="text-[10px] text-warm/90">
|
||||
{missingRecommended.length} recommended key
|
||||
{missingRecommended.length === 1 ? "" : "s"} still unset
|
||||
</span>
|
||||
@@ -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-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
|
||||
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>
|
||||
@@ -346,14 +346,14 @@ function EnvList({
|
||||
? "border-red-800/60 bg-red-950/20"
|
||||
: "border-amber-800/50 bg-amber-950/15";
|
||||
const headerColor =
|
||||
tone === "required" ? "text-red-300" : "text-amber-300";
|
||||
tone === "required" ? "text-bad" : "text-warm";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${accent} p-3`}>
|
||||
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
|
||||
<p className="mt-0.5 mb-2 text-[10px] text-ink-mid">{subtitle}</p>
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) =>
|
||||
typeof entry === "string" ? (
|
||||
@@ -397,16 +397,16 @@ function StrictEnvRow({
|
||||
onSave,
|
||||
}: StrictEnvRowProps) {
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5">
|
||||
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
|
||||
<code
|
||||
className={`text-[11px] font-mono flex-1 ${
|
||||
configured ? "text-zinc-500 line-through" : "text-zinc-200"
|
||||
configured ? "text-ink-soft line-through" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{envKey}
|
||||
</code>
|
||||
{configured ? (
|
||||
<span className="text-[10px] text-emerald-400">✓ set</span>
|
||||
<span className="text-[10px] text-good">✓ set</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
@@ -422,20 +422,20 @@ function StrictEnvRow({
|
||||
}
|
||||
}}
|
||||
disabled={d?.saving}
|
||||
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(envKey)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{d?.error && (
|
||||
<span className="text-[9px] text-red-400 basis-full pl-1">
|
||||
<span className="text-[9px] text-bad basis-full pl-1">
|
||||
{d.error}
|
||||
</span>
|
||||
)}
|
||||
@@ -467,13 +467,13 @@ function AnyOfEnvGroup({
|
||||
}: AnyOfEnvGroupProps) {
|
||||
const satisfiedBy = members.find((m) => configuredKeys.has(m));
|
||||
return (
|
||||
<li className="rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-2">
|
||||
<li className="rounded border border-line bg-surface-sunken/50 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-zinc-400">
|
||||
<span className="text-[10px] uppercase tracking-wide text-ink-mid">
|
||||
Configure any one
|
||||
</span>
|
||||
{satisfiedBy && (
|
||||
<span className="text-[10px] text-emerald-400">
|
||||
<span className="text-[10px] text-good">
|
||||
✓ using <code className="font-mono">{satisfiedBy}</code>
|
||||
</span>
|
||||
)}
|
||||
@@ -486,19 +486,19 @@ function AnyOfEnvGroup({
|
||||
return (
|
||||
<li
|
||||
key={m}
|
||||
className={`flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1 ${
|
||||
className={`flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1 ${
|
||||
dimmed ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<code
|
||||
className={`text-[11px] font-mono flex-1 ${
|
||||
isConfigured ? "text-zinc-500 line-through" : "text-zinc-200"
|
||||
isConfigured ? "text-ink-soft line-through" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</code>
|
||||
{isConfigured ? (
|
||||
<span className="text-[10px] text-emerald-400">✓ set</span>
|
||||
<span className="text-[10px] text-good">✓ set</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
@@ -514,20 +514,20 @@ function AnyOfEnvGroup({
|
||||
}
|
||||
}}
|
||||
disabled={d?.saving}
|
||||
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(m)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{d?.error && (
|
||||
<span className="text-[9px] text-red-400 basis-full pl-1">
|
||||
<span className="text-[9px] text-bad basis-full pl-1">
|
||||
{d.error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -97,27 +97,27 @@ function PlanCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const ring = plan.highlighted
|
||||
? "border-blue-600 ring-2 ring-blue-600/30"
|
||||
: "border-zinc-800";
|
||||
? "border-accent ring-2 ring-blue-600/30"
|
||||
: "border-line";
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col rounded-lg border ${ring} bg-zinc-900/40 p-6`}
|
||||
className={`flex flex-col rounded-lg border ${ring} bg-surface-sunken/40 p-6`}
|
||||
aria-labelledby={`plan-${plan.id}-name`}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<span className="mb-3 inline-block rounded-full bg-blue-600/20 px-3 py-1 text-xs font-medium text-blue-300">
|
||||
<span className="mb-3 inline-block rounded-full bg-accent-strong/20 px-3 py-1 text-xs font-medium text-accent">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-white">
|
||||
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-ink">
|
||||
{plan.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">{plan.tagline}</p>
|
||||
<p className="mt-4 text-3xl font-bold text-white">{plan.price}</p>
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-zinc-300">
|
||||
<p className="mt-1 text-sm text-ink-mid">{plan.tagline}</p>
|
||||
<p className="mt-4 text-3xl font-bold text-ink">{plan.price}</p>
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start">
|
||||
<span className="mr-2 text-blue-400" aria-hidden>
|
||||
<span className="mr-2 text-accent" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
{f}
|
||||
@@ -130,8 +130,8 @@ function PlanCard({
|
||||
disabled={loading}
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
plan.highlighted
|
||||
? "bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-900"
|
||||
: "border border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
{loading ? "Opening checkout…" : plan.ctaLabel}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderModelSelector — single source of truth for the provider→model
|
||||
* dropdown chain shared across:
|
||||
* 1. MissingKeysModal (template deploy / first-time onboarding modal)
|
||||
* 2. ConfigTab (per-workspace settings — Runtime section)
|
||||
* 3. TemplatePalette (template side panel — inherits via MissingKeysModal)
|
||||
*
|
||||
* The user picks Provider FIRST (Anthropic API, Claude Code subscription,
|
||||
* MiniMax, Z.ai GLM, ...). The model dropdown then filters to only that
|
||||
* provider's models. Wildcard providers (huggingface/*, openrouter/*,
|
||||
* custom/*) reveal a free-text model input with a tooltip explaining the
|
||||
* wildcard.
|
||||
*
|
||||
* Provider taxonomy:
|
||||
* - Multiple models can share the same `required_env` (e.g. all
|
||||
* ANTHROPIC_AUTH_TOKEN-routed third-party providers — MiniMax, GLM,
|
||||
* Kimi, DeepSeek). Grouping ONLY by env-tuple collapses them all into
|
||||
* one bucket. We split further by vendor inferred from the model id
|
||||
* so the user sees "MiniMax" and "Z.ai (GLM)" as separate options.
|
||||
* - Vendor is inferred via prefix rules below. Templates that ship
|
||||
* explicit vendor metadata (future) should override the heuristic.
|
||||
*/
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
|
||||
export interface SelectorModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
/** A provider option in the dropdown — one row corresponds to one
|
||||
* vendor + env-tuple combo, holding the models that map to it. */
|
||||
export interface ProviderEntry {
|
||||
/** Stable id used as the <option value>. `${vendor}|${sortedEnv}`. */
|
||||
id: string;
|
||||
/** Inferred vendor key (e.g. "minimax", "anthropic-oauth"). */
|
||||
vendor: string;
|
||||
/** Human label shown in the dropdown. */
|
||||
label: string;
|
||||
/** Env vars required by every model in this provider. */
|
||||
envVars: string[];
|
||||
/** Models bucketed under this provider. */
|
||||
models: SelectorModel[];
|
||||
/** True when ANY model id contains "*" — UI shows free-text model input. */
|
||||
wildcard: boolean;
|
||||
/** Optional tooltip text (rendered as native title=). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface SelectorValue {
|
||||
/** ProviderEntry.id of the selected provider. Empty string = nothing
|
||||
* picked yet (parent should treat as invalid for save). */
|
||||
providerId: string;
|
||||
/** Selected model slug. For wildcard providers this is whatever the
|
||||
* user typed in the free-text input. */
|
||||
model: string;
|
||||
/** Snapshot of envVars from the selected provider. Re-emitted on every
|
||||
* change so consumers can re-render credential fields without
|
||||
* re-inferring from the model. */
|
||||
envVars: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
models: SelectorModel[];
|
||||
value: SelectorValue;
|
||||
onChange: (next: SelectorValue) => void;
|
||||
/** Display variant. "grid" = label+control side-by-side (used in ConfigTab
|
||||
* Runtime section). "stack" = vertical (used in MissingKeysModal). */
|
||||
variant?: "grid" | "stack";
|
||||
/** When true, parent caller is opting in to power-user free-text. Adds a
|
||||
* "Custom (type model id)..." escape-hatch entry as a model option even
|
||||
* when the chosen provider isn't wildcard. ConfigTab uses this; the
|
||||
* deploy modal does not. */
|
||||
allowCustomModelEscape?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Optional id-prefix for label↔control wiring (WCAG 1.3.1). Default
|
||||
* uses useId(). */
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vendor detection — id-prefix heuristic + bare-name patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Vendor keys → human label. Add new vendors here when templates pick
|
||||
* up new model families. */
|
||||
const VENDOR_LABELS: Record<string, string> = {
|
||||
"anthropic-oauth": "Claude Code subscription",
|
||||
anthropic: "Anthropic API",
|
||||
minimax: "MiniMax",
|
||||
zai: "Z.ai (GLM)",
|
||||
moonshot: "Moonshot (Kimi)",
|
||||
deepseek: "DeepSeek",
|
||||
"xiaomi-mimo": "Xiaomi MiMo",
|
||||
openai: "OpenAI",
|
||||
google: "Google Gemini",
|
||||
alibaba: "Alibaba Qwen (DashScope)",
|
||||
nousresearch: "Nous Research (Hermes)",
|
||||
openrouter: "OpenRouter (any model)",
|
||||
huggingface: "Hugging Face Inference",
|
||||
"ai-gateway": "Vercel AI Gateway",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
kilocode: "Kilo Code",
|
||||
"kimi-coding": "Moonshot Kimi (coding-tuned)",
|
||||
"minimax-cn": "MiniMax China",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
ollama: "Ollama (self-hosted)",
|
||||
nvidia: "NVIDIA NIM",
|
||||
arcee: "Arcee",
|
||||
xiaomi: "Xiaomi MiMo",
|
||||
gemini: "Google Gemini",
|
||||
custom: "Custom OpenAI-compat endpoint",
|
||||
};
|
||||
|
||||
/** Optional per-vendor tooltip shown on hover. */
|
||||
const VENDOR_TOOLTIPS: Record<string, string> = {
|
||||
"anthropic-oauth":
|
||||
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
|
||||
anthropic:
|
||||
"Pay-per-token via the Anthropic API (Console). Provide an API key starting with sk-ant-…",
|
||||
minimax:
|
||||
"MiniMax models served through their Anthropic-API-compatible endpoint. Get a key at platform.minimax.io.",
|
||||
zai:
|
||||
"Zhipu AI / z.ai GLM models through the Anthropic-compatible gateway. Get a key at docs.z.ai.",
|
||||
moonshot:
|
||||
"Moonshot Kimi K2-series via Anthropic-API-compatible endpoint. Get a key at platform.kimi.ai.",
|
||||
deepseek:
|
||||
"DeepSeek V4 via Anthropic-API-compatible endpoint. Get a key at api-docs.deepseek.com.",
|
||||
openrouter:
|
||||
"OpenRouter routes to 200+ models behind one API. Use any openrouter/<model> id. Get a key at openrouter.ai.",
|
||||
huggingface:
|
||||
"Any model hosted on Hugging Face Inference. Type the full model id (e.g. mistralai/Mistral-7B-Instruct-v0.3).",
|
||||
custom:
|
||||
"Self-hosted OpenAI-compatible endpoint (LM Studio, Ollama local, vLLM, llama.cpp). Configure base_url in the workspace's runtime config. No API key required.",
|
||||
};
|
||||
|
||||
/** Sentinel value used in the model <select> for the free-text escape hatch
|
||||
* added by `allowCustomModelEscape`. The component swaps to a text input
|
||||
* when this is selected. */
|
||||
const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
/** Bare-id vendor patterns (no slash separator). Order matters — first
|
||||
* match wins. */
|
||||
const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: string }> = [
|
||||
{ test: (id) => /^minimax-/i.test(id) || /^MiniMax-/.test(id), vendor: "minimax" },
|
||||
{ test: (id) => /^GLM-/i.test(id), vendor: "zai" },
|
||||
{ test: (id) => /^kimi-/i.test(id), vendor: "moonshot" },
|
||||
{ test: (id) => /^deepseek-/i.test(id), vendor: "deepseek" },
|
||||
{ test: (id) => /^mimo-/i.test(id), vendor: "xiaomi-mimo" },
|
||||
{ test: (id) => /^claude-/i.test(id), vendor: "anthropic" },
|
||||
{ test: (id) => /^gpt-/i.test(id), vendor: "openai" },
|
||||
{ test: (id) => /^gemini-/i.test(id), vendor: "google" },
|
||||
{ test: (id) => /^qwen-/i.test(id), vendor: "alibaba" },
|
||||
// Claude-Code OAuth aliases — bare "sonnet"/"opus"/"haiku" + CLAUDE_CODE_OAUTH_TOKEN
|
||||
// is the strongest signal that this is a subscription model. We also
|
||||
// gate on env in inferVendor() below to avoid mis-tagging non-OAuth
|
||||
// models that happen to be named "sonnet".
|
||||
{ test: (id) => /^(sonnet|opus|haiku)$/i.test(id), vendor: "anthropic-oauth" },
|
||||
];
|
||||
|
||||
/** Infer a vendor key from a model spec. Combines id-prefix and env
|
||||
* signals. Exported for tests. */
|
||||
export function inferVendor(model: SelectorModel): string {
|
||||
const id = model.id || "";
|
||||
const envSet = new Set(model.required_env ?? []);
|
||||
|
||||
// 1. Explicit slash-separated prefix wins (e.g. nousresearch/hermes-4-70b).
|
||||
const slashIdx = id.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
return id.slice(0, slashIdx).toLowerCase();
|
||||
}
|
||||
|
||||
// 2. Bare-id pattern. Special-case the OAuth aliases — they only count
|
||||
// when the env actually demands the OAuth token. Otherwise (e.g.
|
||||
// a hypothetical "sonnet" alias against ANTHROPIC_API_KEY) fall
|
||||
// through and let the env-based fallback bucket it under
|
||||
// "anthropic".
|
||||
for (const p of BARE_VENDOR_PATTERNS) {
|
||||
if (!p.test(id)) continue;
|
||||
if (p.vendor === "anthropic-oauth" && !envSet.has("CLAUDE_CODE_OAUTH_TOKEN")) {
|
||||
continue;
|
||||
}
|
||||
return p.vendor;
|
||||
}
|
||||
|
||||
// 3. Env-tuple fallback. Pick the first env's "namespace" as the
|
||||
// vendor — e.g. OPENROUTER_API_KEY → "openrouter".
|
||||
const env = model.required_env?.[0];
|
||||
if (env) {
|
||||
const ns = env.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "").toLowerCase();
|
||||
return ns || "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Build the provider catalog from the template's models[]. Models are
|
||||
* bucketed by `(vendor, sortedEnv)` so two distinct env-tuples for the
|
||||
* same vendor (rare but possible) become two separate entries. */
|
||||
export function buildProviderCatalog(models: SelectorModel[]): ProviderEntry[] {
|
||||
const buckets = new Map<string, ProviderEntry>();
|
||||
|
||||
for (const m of models) {
|
||||
const envs = m.required_env ?? [];
|
||||
const sortedEnv = [...envs].sort().join("|");
|
||||
const vendor = inferVendor(m);
|
||||
const id = `${vendor}|${sortedEnv}`;
|
||||
const wildcard = m.id.includes("*");
|
||||
|
||||
let entry = buckets.get(id);
|
||||
if (!entry) {
|
||||
const baseLabel = VENDOR_LABELS[vendor] ?? vendor;
|
||||
entry = {
|
||||
id,
|
||||
vendor,
|
||||
label: baseLabel,
|
||||
envVars: envs,
|
||||
models: [],
|
||||
wildcard,
|
||||
tooltip: VENDOR_TOOLTIPS[vendor],
|
||||
};
|
||||
buckets.set(id, entry);
|
||||
}
|
||||
entry.models.push(m);
|
||||
// Wildcard sticks if any model in the bucket is a wildcard — same
|
||||
// bucket can't mix wildcard and concrete because they'd typically
|
||||
// share required_env but rarely the same vendor. Defensive OR.
|
||||
entry.wildcard = entry.wildcard || wildcard;
|
||||
}
|
||||
|
||||
// Decorate label with model-count when ≥2 concrete models share the
|
||||
// bucket. Helps the user understand "Anthropic API (5 models)" vs
|
||||
// "MiniMax (3 models)".
|
||||
for (const e of buckets.values()) {
|
||||
if (!e.wildcard && e.models.length > 1) {
|
||||
e.label = `${e.label} (${e.models.length} models)`;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values());
|
||||
}
|
||||
|
||||
/** Find the provider entry that contains a given model id. Used by
|
||||
* callers to back-derive the provider when only the model is known
|
||||
* (e.g. ConfigTab loading from saved state). */
|
||||
export function findProviderForModel(
|
||||
catalog: ProviderEntry[],
|
||||
modelId: string,
|
||||
): ProviderEntry | null {
|
||||
if (!modelId) return null;
|
||||
for (const p of catalog) {
|
||||
if (p.models.some((m) => m.id === modelId)) return p;
|
||||
// Wildcard match — entry has model id ending in "*" and the typed
|
||||
// id starts with the wildcard's prefix (e.g. "openrouter/anthropic/
|
||||
// claude-3.5-sonnet" matches the "openrouter/*" bucket).
|
||||
if (p.wildcard) {
|
||||
for (const m of p.models) {
|
||||
if (!m.id.endsWith("*")) continue;
|
||||
const prefix = m.id.slice(0, -1);
|
||||
if (modelId.startsWith(prefix)) return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Component
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function ProviderModelSelector({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
variant = "stack",
|
||||
allowCustomModelEscape = false,
|
||||
disabled = false,
|
||||
idPrefix,
|
||||
}: Props) {
|
||||
const generatedId = useId();
|
||||
const baseId = idPrefix ?? generatedId;
|
||||
const providerSelectId = `${baseId}-provider`;
|
||||
const modelSelectId = `${baseId}-model`;
|
||||
|
||||
const catalog = useMemo(() => buildProviderCatalog(models), [models]);
|
||||
const selected = useMemo(
|
||||
() => catalog.find((p) => p.id === value.providerId) ?? null,
|
||||
[catalog, value.providerId],
|
||||
);
|
||||
|
||||
// True when the user picked the "Custom (type model id)..." escape entry
|
||||
// in the model dropdown — switches to free-text. Wildcard providers
|
||||
// ALWAYS use free-text, so this flag is for the escape hatch on
|
||||
// non-wildcard providers.
|
||||
const userPickedCustom = value.model === CUSTOM_MODEL_SENTINEL || (
|
||||
!!selected &&
|
||||
!selected.wildcard &&
|
||||
!!value.model &&
|
||||
!selected.models.some((m) => m.id === value.model)
|
||||
);
|
||||
const useTextInput = (selected?.wildcard ?? false) || userPickedCustom;
|
||||
|
||||
const handleProviderChange = (nextProviderId: string) => {
|
||||
const next = catalog.find((p) => p.id === nextProviderId) ?? null;
|
||||
if (!next) {
|
||||
onChange({ providerId: "", model: "", envVars: [] });
|
||||
return;
|
||||
}
|
||||
// When switching providers:
|
||||
// - wildcard provider → empty (free-text input takes over)
|
||||
// - exactly 1 concrete model → auto-pick (no choice to make)
|
||||
// - 2+ concrete models → leave empty so the operator MUST pick
|
||||
//
|
||||
// Background: previously this defaulted to `next.models[0]` for any
|
||||
// non-wildcard provider, which silently set the alphabetically-first
|
||||
// model in the bucket. Bit a real user on 2026-05-03 — they picked
|
||||
// the MiniMax provider intending `MiniMax-M2.7` but the form silently
|
||||
// set `MiniMax-M2` (first in the list). They never saw the model
|
||||
// dropdown change because the provider+model widgets are visually
|
||||
// distinct, and the workspace deployed with the wrong model. Caller
|
||||
// already disables Deploy/Save while `model.trim() === ""`, so the
|
||||
// empty default forces an explicit pick without loosening any other
|
||||
// gate.
|
||||
const defaultModel = next.wildcard
|
||||
? ""
|
||||
: next.models.length === 1
|
||||
? next.models[0]?.id ?? ""
|
||||
: "";
|
||||
onChange({
|
||||
providerId: next.id,
|
||||
model: defaultModel,
|
||||
envVars: next.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelChange = (nextModel: string) => {
|
||||
if (!selected) {
|
||||
onChange({ ...value, model: nextModel });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
providerId: selected.id,
|
||||
model: nextModel,
|
||||
envVars: selected.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const containerClass = variant === "grid" ? "grid grid-cols-2 gap-3" : "space-y-3";
|
||||
|
||||
return (
|
||||
<div className={containerClass} data-testid="provider-model-selector">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={providerSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||
>
|
||||
Provider <span aria-hidden="true" className="text-bad">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<select
|
||||
id={providerSelectId}
|
||||
value={value.providerId}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
disabled={disabled || catalog.length === 0}
|
||||
aria-describedby={selected?.tooltip ? `${providerSelectId}-help` : undefined}
|
||||
data-testid="provider-select"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
— select provider —
|
||||
</option>
|
||||
{catalog.map((p) => (
|
||||
<option key={p.id} value={p.id} title={p.tooltip}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected?.tooltip && (
|
||||
<p
|
||||
id={`${providerSelectId}-help`}
|
||||
className="text-[9px] text-ink-soft mt-1 leading-relaxed"
|
||||
>
|
||||
{selected.tooltip}
|
||||
</p>
|
||||
)}
|
||||
{selected && selected.envVars.length > 0 && (
|
||||
<p className="text-[9px] text-ink-soft mt-0.5 font-mono">
|
||||
requires: {selected.envVars.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={modelSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||
>
|
||||
Model <span aria-hidden="true" className="text-bad">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
{useTextInput ? (
|
||||
<>
|
||||
<input
|
||||
id={modelSelectId}
|
||||
type="text"
|
||||
value={
|
||||
value.model === CUSTOM_MODEL_SENTINEL ? "" : value.model
|
||||
}
|
||||
onChange={(e) => handleModelChange(e.target.value.trim())}
|
||||
placeholder={
|
||||
selected?.wildcard
|
||||
? wildcardPlaceholder(selected)
|
||||
: "type any model id"
|
||||
}
|
||||
disabled={disabled || !selected}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
data-testid="model-input"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
|
||||
{selected?.wildcard
|
||||
? wildcardHelpText(selected)
|
||||
: "Free-text model id. Make sure the provider can resolve it."}
|
||||
</p>
|
||||
{!selected?.wildcard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Switch back to dropdown by setting model to first
|
||||
// concrete option.
|
||||
if (selected) {
|
||||
handleModelChange(selected.models[0]?.id ?? "");
|
||||
}
|
||||
}}
|
||||
className="text-[9px] text-accent hover:text-accent mt-0.5"
|
||||
>
|
||||
← back to model list
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id={modelSelectId}
|
||||
value={
|
||||
value.model && selected?.models.some((m) => m.id === value.model)
|
||||
? value.model
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
handleModelChange(CUSTOM_MODEL_SENTINEL);
|
||||
} else {
|
||||
handleModelChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || !selected || selected.models.length === 0}
|
||||
data-testid="model-select"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selected ? "— select model —" : "— select provider first —"}
|
||||
</option>
|
||||
{selected?.models
|
||||
.filter((m) => !m.id.includes("*"))
|
||||
.map((m) => (
|
||||
<option
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
title={m.name ?? m.id}
|
||||
>
|
||||
{m.name ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
{allowCustomModelEscape && selected && (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
Custom (type model id)…
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function wildcardPlaceholder(p: ProviderEntry): string {
|
||||
const example = p.models.find((m) => m.id.includes("*"))?.id ?? "";
|
||||
if (!example) return "type any model id";
|
||||
// Strip trailing star — show the pattern as a hint.
|
||||
const prefix = example.replace(/\*$/, "");
|
||||
switch (p.vendor) {
|
||||
case "huggingface":
|
||||
return `e.g. ${prefix}meta-llama/Meta-Llama-3-70B-Instruct`;
|
||||
case "openrouter":
|
||||
return `e.g. ${prefix}anthropic/claude-3.5-sonnet`;
|
||||
case "custom":
|
||||
return `e.g. ${prefix}my-local-model`;
|
||||
default:
|
||||
return `e.g. ${prefix}<model-id>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardHelpText(p: ProviderEntry): string {
|
||||
switch (p.vendor) {
|
||||
case "huggingface":
|
||||
return "Any model hosted on Hugging Face Inference. Browse at huggingface.co/models?inference=warm.";
|
||||
case "openrouter":
|
||||
return "Any of OpenRouter's 200+ routed models. Browse at openrouter.ai/models.";
|
||||
case "custom":
|
||||
return "Self-hosted endpoint. Configure base_url in your workspace's runtime config (no API key required).";
|
||||
case "ai-gateway":
|
||||
return "Vercel AI Gateway model id. See vercel.com/docs/ai-gateway.";
|
||||
case "opencode-zen":
|
||||
return "OpenCode Zen model id. See opencode.zen.";
|
||||
default:
|
||||
return "Wildcard provider — type the model id in full. Provider routes by id prefix.";
|
||||
}
|
||||
}
|
||||
@@ -321,17 +321,17 @@ 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-amber-400/60 hover:text-amber-200 transition-colors -mr-1"
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-300/80 leading-relaxed">
|
||||
<div className="text-[11px] text-warm/80 leading-relaxed">
|
||||
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
|
||||
has been provisioning for{" "}
|
||||
<span className="font-mono text-amber-300">{formatDuration(elapsed)}</span>.
|
||||
<span className="font-mono text-warm">{formatDuration(elapsed)}</span>.
|
||||
It may have encountered an issue.
|
||||
</div>
|
||||
|
||||
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
|
||||
type="button"
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
|
||||
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-amber-400 hover:text-amber-300 transition-colors"
|
||||
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
|
||||
>
|
||||
View Logs
|
||||
</button>
|
||||
@@ -371,18 +371,18 @@ export function ProvisioningTimeout({
|
||||
{confirmingCancel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
|
||||
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-ink mb-2">
|
||||
Cancel deployment?
|
||||
</h3>
|
||||
<p className="text-[12px] text-zinc-400 mb-4 leading-relaxed">
|
||||
<p className="text-[12px] text-ink-mid mb-4 leading-relaxed">
|
||||
This will permanently remove the workspace. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
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>
|
||||
|
||||
@@ -36,11 +36,6 @@ export function SearchDialog() {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset focused index when query changes
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
const filtered = nodes.filter((n) => {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -51,6 +46,18 @@ export function SearchDialog() {
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-highlight the first match while the user is typing, so Enter
|
||||
// selects something instead of being a no-op. With an empty query we
|
||||
// keep -1 so opening the dialog (which shows ALL workspaces) doesn't
|
||||
// visually pin one row arbitrarily — only commit a highlight once the
|
||||
// user has narrowed the list.
|
||||
useEffect(() => {
|
||||
setFocusedIndex(query && filtered.length > 0 ? 0 : -1);
|
||||
// Re-running on filtered.length keeps the highlight pinned to the
|
||||
// first row while the result set shrinks/grows; the effect handler
|
||||
// above already short-circuits to -1 when results disappear.
|
||||
}, [query, filtered.length]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(nodeId: string) => {
|
||||
selectNode(nodeId);
|
||||
@@ -92,12 +99,12 @@ export function SearchDialog() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500" aria-hidden="true">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
@@ -113,9 +120,9 @@ export function SearchDialog() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded"
|
||||
className="flex-1 bg-transparent text-sm text-ink placeholder-ink-soft focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
/>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
@@ -126,7 +133,7 @@ export function SearchDialog() {
|
||||
className="max-h-[300px] overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-400">
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-ink-mid">
|
||||
{query ? "No workspaces match" : "No workspaces yet"}
|
||||
</div>
|
||||
) : (
|
||||
@@ -139,7 +146,7 @@ export function SearchDialog() {
|
||||
aria-selected={index === focusedIndex}
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
|
||||
index === focusedIndex ? "bg-surface-card/60" : "hover:bg-surface-card/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -147,13 +154,13 @@ export function SearchDialog() {
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${statusDotClass(node.data.status)}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
|
||||
<div className="text-sm text-ink truncate">{node.data.name}</div>
|
||||
{node.data.role && (
|
||||
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
|
||||
<div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-mono text-zinc-400"
|
||||
className="text-[9px] font-mono text-ink-mid"
|
||||
aria-label={`Tier ${node.data.tier}`}
|
||||
>
|
||||
T{node.data.tier}
|
||||
@@ -164,11 +171,11 @@ export function SearchDialog() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-400">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="px-4 py-2 border-t border-line/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-ink-mid">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">↵ select</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SidePanel() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full bg-zinc-950/95 backdrop-blur-xl border-l border-zinc-800/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
|
||||
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 */}
|
||||
@@ -151,26 +151,26 @@ export function SidePanel() {
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
onKeyDown={onResizeKeyDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
|
||||
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-5 py-4 border-b border-zinc-800/40 bg-zinc-900/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" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[14px] font-semibold text-zinc-100 truncate leading-tight">
|
||||
<h2 className="text-[14px] font-semibold text-ink truncate leading-tight">
|
||||
{node.data.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{node.data.role && (
|
||||
<span className="text-[10px] text-zinc-500 truncate">
|
||||
<span className="text-[10px] text-ink-soft truncate">
|
||||
{node.data.role}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
|
||||
isOnline ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500 bg-zinc-800/50"
|
||||
isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
|
||||
}`}>
|
||||
T{node.data.tier}
|
||||
</span>
|
||||
@@ -181,7 +181,7 @@ export function SidePanel() {
|
||||
type="button"
|
||||
onClick={() => selectNode(null)}
|
||||
aria-label="Close workspace panel"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
@@ -190,7 +190,7 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Capability summary */}
|
||||
<div className="px-5 py-3 border-b border-zinc-800/40 bg-zinc-900/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"} />
|
||||
@@ -200,13 +200,13 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
|
||||
<div className="relative border-b border-zinc-800/40">
|
||||
<div className="relative border-b border-line/40">
|
||||
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent z-10" aria-hidden="true" />
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Workspace panel tabs"
|
||||
className="flex overflow-x-auto bg-zinc-900/20 px-1"
|
||||
className="flex overflow-x-auto bg-surface-sunken/20 px-1"
|
||||
onKeyDown={(e) => {
|
||||
const idx = TABS.findIndex((t) => t.id === panelTab);
|
||||
let next: number | null = null;
|
||||
@@ -230,10 +230,10 @@ export function SidePanel() {
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabIndex={panelTab === tab.id ? 0 : -1}
|
||||
onClick={() => setPanelTab(tab.id)}
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 ${
|
||||
panelTab === tab.id
|
||||
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40"
|
||||
? "text-ink bg-surface-card border-b-2 border-accent"
|
||||
: "text-ink-mid hover:text-ink hover:bg-surface-card/60"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
|
||||
@@ -264,7 +264,7 @@ export function SidePanel() {
|
||||
<Tooltip text={node.data.currentTask as string}>
|
||||
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300/90 truncate">
|
||||
<span className="text-[10px] text-warm/90 truncate">
|
||||
{node.data.currentTask}
|
||||
</span>
|
||||
</div>
|
||||
@@ -295,8 +295,8 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Footer — workspace ID */}
|
||||
<div className="px-5 py-2 border-t border-zinc-800/40 bg-zinc-900/20">
|
||||
<span className="text-[9px] font-mono text-zinc-500 select-all">
|
||||
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
||||
<span className="text-[9px] font-mono text-ink-soft select-all">
|
||||
{selectedNodeId}
|
||||
</span>
|
||||
</div>
|
||||
@@ -306,9 +306,9 @@ export function SidePanel() {
|
||||
|
||||
function MetaPill({ label, value, tone = "zinc" }: { label: string; value: string; tone?: "zinc" | "emerald" | "amber" }) {
|
||||
const toneClasses = {
|
||||
zinc: "border-zinc-700/50 bg-zinc-900/70 text-zinc-400",
|
||||
emerald: "border-emerald-500/20 bg-emerald-950/20 text-emerald-300",
|
||||
amber: "border-amber-500/20 bg-amber-950/20 text-amber-300",
|
||||
zinc: "border-line/50 bg-surface-sunken/70 text-ink-mid",
|
||||
emerald: "border-emerald-500/20 bg-emerald-950/20 text-good",
|
||||
amber: "border-amber-500/20 bg-amber-950/20 text-warm",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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-zinc-500 hover:text-zinc-300 font-semibold transition-colors"
|
||||
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
|
||||
</span>
|
||||
Org Templates
|
||||
{orgs.length > 0 && (
|
||||
<span className="text-zinc-600 normal-case tracking-normal">
|
||||
<span className="text-ink-soft normal-case tracking-normal">
|
||||
({orgs.length})
|
||||
</span>
|
||||
)}
|
||||
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
|
||||
type="button"
|
||||
onClick={loadOrgs}
|
||||
aria-label="Refresh org templates"
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
className="text-[10px] text-ink-soft hover:text-ink-mid"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
@@ -264,20 +264,20 @@ export function OrgTemplatesSection() {
|
||||
{expanded && (
|
||||
<div id="org-templates-body" className="space-y-2">
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-zinc-500">
|
||||
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
|
||||
<Spinner size="sm" />
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && orgs.length === 0 && (
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
<div className="text-[10px] text-ink-soft">
|
||||
No org templates in <code>org-templates/</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-red-400">
|
||||
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -287,10 +287,10 @@ export function OrgTemplatesSection() {
|
||||
return (
|
||||
<div
|
||||
key={o.dir}
|
||||
className="bg-zinc-900/50 border border-zinc-800/60 rounded-xl p-3 hover:border-zinc-700/60 transition-all"
|
||||
className="bg-surface-sunken/50 border border-line/60 rounded-xl p-3 hover:border-line/60 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-semibold text-zinc-200 truncate">
|
||||
<span className="text-[12px] font-semibold text-ink truncate">
|
||||
{o.name || o.dir}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-sky-400 bg-sky-950/40 px-1.5 py-0.5 rounded-md shrink-0">
|
||||
@@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
|
||||
</span>
|
||||
</div>
|
||||
{o.description && (
|
||||
<p className="text-[10px] text-zinc-500 mb-2.5 line-clamp-2 leading-relaxed">
|
||||
<p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
|
||||
{o.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
|
||||
type="button"
|
||||
onClick={() => handleImport(o)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
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-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
|
||||
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>
|
||||
@@ -476,8 +476,8 @@ export function TemplatePalette() {
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
open
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-900/90 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600"
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
|
||||
}`}
|
||||
title="Template Palette"
|
||||
aria-label={open ? "Close template palette" : "Open template palette"}
|
||||
@@ -496,10 +496,10 @@ export function TemplatePalette() {
|
||||
|
||||
{/* Sidebar */}
|
||||
{open && (
|
||||
<div className="fixed top-0 left-0 h-full w-[280px] bg-zinc-900/95 backdrop-blur-md border-r border-zinc-800/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
||||
<div className="px-4 pt-14 pb-3 border-b border-zinc-800/60">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Templates</h2>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">Click to deploy a workspace</p>
|
||||
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
||||
<div className="px-4 pt-14 pb-3 border-b border-line/60">
|
||||
<h2 className="text-sm font-semibold text-ink">Templates</h2>
|
||||
<p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
@@ -509,20 +509,20 @@ export function TemplatePalette() {
|
||||
<OrgTemplatesSection />
|
||||
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
|
||||
<Spinner />
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.length === 0 && (
|
||||
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
|
||||
No templates found in<br />workspace-configs-templates/
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -537,10 +537,10 @@ export function TemplatePalette() {
|
||||
key={t.id}
|
||||
onClick={() => void handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800/40 disabled:hover:border-zinc-700/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
className="w-full text-left bg-surface-card/40 hover:bg-surface-card/70 border border-line/40 hover:border-line/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-surface-card/40 disabled:hover:border-line/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-semibold text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
<span className="text-[12px] font-semibold text-ink group-hover:text-ink truncate">
|
||||
{t.name}
|
||||
</span>
|
||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded-md shrink-0 ${tierCfg.color}`}>
|
||||
@@ -549,7 +549,7 @@ export function TemplatePalette() {
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
|
||||
<p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -557,12 +557,12 @@ export function TemplatePalette() {
|
||||
{t.skills?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.skills.slice(0, 3).map((s) => (
|
||||
<span key={s} className="text-[8px] text-zinc-400 bg-zinc-700/40 px-1.5 py-0.5 rounded">
|
||||
<span key={s} className="text-[8px] text-ink-mid bg-surface-card/40 px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
{t.skills.length > 3 && (
|
||||
<span className="text-[8px] text-zinc-500">+{t.skills.length - 3}</span>
|
||||
<span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -575,12 +575,12 @@ export function TemplatePalette() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<div className="px-4 py-3 border-t border-line/60 space-y-3">
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTemplates}
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
|
||||
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
|
||||
>
|
||||
Refresh templates
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
|
||||
// TermsGate blocks the page it wraps until the user has accepted the
|
||||
@@ -73,39 +73,72 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Move focus to the "I agree" button when the modal opens (WCAG 2.4.3).
|
||||
// The dialog is a hard gate — no Esc dismiss — so we don't need a focus
|
||||
// trap loop, just a one-shot focus move into the dialog.
|
||||
const agreeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (status !== "pending") return;
|
||||
const raf = requestAnimationFrame(() => agreeButtonRef.current?.focus());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
// Backdrop is decorative — does NOT carry aria-hidden anymore.
|
||||
// The earlier version put aria-hidden="true" on this wrapper,
|
||||
// which hid the dialog AND its descendants from screen readers,
|
||||
// making the entire terms-acceptance flow invisible to AT users.
|
||||
// Backdrop click intentionally does nothing — this is a hard
|
||||
// gate.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-dialog-title"
|
||||
className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl"
|
||||
aria-describedby="terms-dialog-body"
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-white">Terms & conditions</h2>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/legal/privacy" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Click agree to continue.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms & conditions</h2>
|
||||
<div id="terms-dialog-body">
|
||||
<p className="mt-3 text-sm text-ink-mid">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a
|
||||
href="/legal/terms"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/legal/privacy"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Click agree to continue.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
ref={agreeButtonRef}
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
// 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: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>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
|
||||
|
||||
const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [
|
||||
// Sun: explicit light
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: "M12 3v1.5M12 19.5V21M4.22 4.22l1.06 1.06M18.72 18.72l1.06 1.06M3 12h1.5M19.5 12H21M4.22 19.78l1.06-1.06M18.72 5.28l1.06-1.06M16 12a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
},
|
||||
// Monitor: follow OS
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: "M3 5h18v11H3zM8 21h8M9 21l1-5h4l1 5",
|
||||
},
|
||||
// Moon: explicit dark
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: "M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Three-way preference picker: System / Light / Dark.
|
||||
*
|
||||
* Highlights the user's *picked* preference, not the resolved render
|
||||
* mode. So "System" stays highlighted while the screen renders dark
|
||||
* (because the OS is dark) — that's the user's mental model: "I told
|
||||
* the app to follow my OS."
|
||||
*
|
||||
* Aligned with molecule-app/components/theme-toggle.tsx so the picker
|
||||
* behaves identically across surfaces.
|
||||
*/
|
||||
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
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) => {
|
||||
const active = theme === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={opt.label}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={
|
||||
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
|
||||
(active
|
||||
? "bg-surface-elevated text-ink shadow-sm"
|
||||
: "text-ink-soft hover:text-ink-mid")
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width={13}
|
||||
height={13}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={opt.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,15 +38,38 @@ export function Toaster() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Esc dismisses the newest toast — keyboard parity with the × button.
|
||||
// Errors never auto-expire, so without this a keyboard-only user has to
|
||||
// tab through the entire app to reach the dismiss button on a stuck error.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const toastCls = (type: Toast["type"]) =>
|
||||
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
type === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||||
: type === "error"
|
||||
? "bg-red-950/90 border border-red-700/40 text-red-200"
|
||||
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
|
||||
: "bg-surface-sunken/90 border border-line/40 text-ink"
|
||||
}`;
|
||||
|
||||
// Success/error toasts are intentionally dark in both themes (high-vis).
|
||||
// Info uses the semantic surface that flips with theme — so the dismiss
|
||||
// button needs a tint that stays visible on a light bg in light mode.
|
||||
const dismissCls = (type: Toast["type"]) => {
|
||||
const base =
|
||||
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
|
||||
return type === "info"
|
||||
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
|
||||
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
|
||||
};
|
||||
|
||||
const pos =
|
||||
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
|
||||
|
||||
@@ -66,7 +89,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -94,7 +117,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingsButton } from "@/components/settings/SettingsButton";
|
||||
import { settingsGearRef } from "@/components/settings/SettingsPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { statusDotClass } from "@/lib/design-tokens";
|
||||
|
||||
export function Toolbar() {
|
||||
@@ -128,13 +129,13 @@ export function Toolbar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
|
||||
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
|
||||
style={toolbarOffsetStyle}
|
||||
>
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-2 pr-3 border-r border-zinc-800/60">
|
||||
<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="text-[11px] font-semibold text-zinc-300 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
|
||||
@@ -153,15 +154,15 @@ export function Toolbar() {
|
||||
{counts.failed > 0 && (
|
||||
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
|
||||
)}
|
||||
<span className="text-zinc-700" aria-hidden="true">·</span>
|
||||
<span className="text-[10px] text-zinc-500 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-zinc-600"> + {counts.children} sub</span>}
|
||||
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* WebSocket connection status */}
|
||||
<div className="pl-3 border-l border-zinc-800/60">
|
||||
<div className="pl-3 border-l border-line/60">
|
||||
<WsStatusPill status={wsStatus} />
|
||||
</div>
|
||||
|
||||
@@ -171,14 +172,14 @@ export function Toolbar() {
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-bad/10 hover:bg-bad/20 border border-bad/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-bad/40"
|
||||
title={`Stop all running tasks (${counts.activeTasks} active)`}
|
||||
aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-red-400" aria-hidden="true">
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-bad" aria-hidden="true">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-red-300 font-medium">
|
||||
<span className="text-[10px] text-bad font-medium">
|
||||
{stopping ? "Stopping..." : `Stop All (${counts.activeTasks})`}
|
||||
</span>
|
||||
</button>
|
||||
@@ -190,14 +191,14 @@ export function Toolbar() {
|
||||
type="button"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-warm/10 hover:bg-warm/20 border border-warm/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-warm/40"
|
||||
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
|
||||
aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400" aria-hidden="true">
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-warm" aria-hidden="true">
|
||||
<path d="M2 8a6 6 0 1 1 1.76 4.24M2 13v-3h3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-amber-300 font-medium">
|
||||
<span className="text-[10px] text-warm font-medium">
|
||||
{restartingAll ? "Restarting..." : `Restart Pending (${needsRestartNodes.length})`}
|
||||
</span>
|
||||
</button>
|
||||
@@ -215,10 +216,10 @@ export function Toolbar() {
|
||||
aria-pressed={showA2AEdges}
|
||||
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
|
||||
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
|
||||
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
|
||||
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
showA2AEdges
|
||||
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
|
||||
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
|
||||
? "bg-accent/15 hover:bg-accent/25 border-accent/50 text-accent"
|
||||
: "bg-surface-card hover:bg-surface-card/70 border-line text-ink-mid hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{/* Mesh / network icon */}
|
||||
@@ -254,7 +255,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-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
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
|
||||
@@ -276,7 +277,7 @@ export function Toolbar() {
|
||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||
aria-label="Search workspaces"
|
||||
title="Search (⌘K)"
|
||||
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
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"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
@@ -289,7 +290,7 @@ export function Toolbar() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open quick help"
|
||||
title="Help — shortcuts & quick start"
|
||||
@@ -301,13 +302,13 @@ export function Toolbar() {
|
||||
</button>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-zinc-700/60 bg-zinc-950/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -324,6 +325,9 @@ export function Toolbar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme picker — System / Light / Dark */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<SettingsButton ref={settingsGearRef} />
|
||||
|
||||
@@ -344,7 +348,7 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={`${count} ${label}`} aria-label={`${count} ${label}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-400 tabular-nums" aria-hidden="true">{count}</span>
|
||||
<span className="text-[10px] text-ink-mid tabular-nums" aria-hidden="true">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -354,7 +358,7 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">Live</span>
|
||||
<span className="text-[10px] text-ink-mid" aria-hidden="true">Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -362,25 +366,25 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">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" aria-label="Real-time updates: disconnected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-zinc-500" aria-hidden="true">Offline</span>
|
||||
<span className="text-[10px] text-bad" aria-hidden="true">Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
|
||||
<span className="shrink-0 rounded-md border border-zinc-700/60 bg-zinc-950/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-zinc-400">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-line/70 bg-surface-sunken/45 px-3 py-2">
|
||||
<span className="shrink-0 rounded-md border border-line/60 bg-surface/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-ink-mid">
|
||||
{shortcut}
|
||||
</span>
|
||||
<p className="text-[11px] leading-relaxed text-zinc-500">{text}</p>
|
||||
<p className="text-[11px] leading-relaxed text-ink-mid">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) {
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
// WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism
|
||||
// is available to dismiss the additional content WITHOUT moving
|
||||
// pointer hover or keyboard focus. Esc dismisses while the trigger
|
||||
// stays focused/hovered, so a screen-magnifier user can read what
|
||||
// the tooltip was covering without losing their place.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
clearTimeout(timerRef.current);
|
||||
setShow(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [show]);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (triggerRef.current) {
|
||||
@@ -66,10 +84,10 @@ export function Tooltip({ text, children }: Props) {
|
||||
<div
|
||||
id={tooltipId.current}
|
||||
role="tooltip"
|
||||
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg shadow-2xl shadow-black/60 pointer-events-none"
|
||||
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto px-3 py-2 bg-surface-card border border-line rounded-lg shadow-2xl shadow-black/60 pointer-events-none"
|
||||
style={{ left: pos.x, top: Math.max(8, pos.y - 8), transform: "translateY(-100%)" }}
|
||||
>
|
||||
<div className="text-[11px] text-zinc-200 whitespace-pre-wrap break-words leading-relaxed">
|
||||
<div className="text-[11px] text-ink whitespace-pre-wrap break-words leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -36,7 +36,7 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
// Org-deploy context — four derived flags off one store subscription.
|
||||
// Drives the shimmer while provisioning, the dimmed/non-draggable
|
||||
// treatment on locked descendants, and the Cancel pill on the root.
|
||||
@@ -69,8 +69,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
isVisible={isSelected}
|
||||
minWidth={hasChildren ? 360 : 210}
|
||||
minHeight={hasChildren ? 200 : 110}
|
||||
lineClassName="!border-blue-500/40"
|
||||
handleClassName="!w-2 !h-2 !bg-blue-500 !border !border-blue-300"
|
||||
lineClassName="!border-accent/40"
|
||||
handleClassName="!w-2 !h-2 !bg-accent !border !border-blue-300"
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
@@ -137,13 +137,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
${isDragTarget
|
||||
? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]"
|
||||
: isBatchSelected
|
||||
? "bg-zinc-900/95 border-2 border-blue-500/80 ring-2 ring-blue-500/30 shadow-lg shadow-blue-500/15"
|
||||
? "bg-surface-sunken/95 border-2 border-accent/80 ring-2 ring-accent/30 shadow-lg shadow-blue-500/15"
|
||||
: isSelected
|
||||
? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10"
|
||||
: "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
? "bg-surface-sunken/95 border border-accent/70 ring-1 ring-accent/30 shadow-lg shadow-blue-500/10"
|
||||
: "bg-surface-sunken/90 border border-line/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
}
|
||||
backdrop-blur-sm
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
|
||||
${deploy.isActivelyProvisioning ? "mol-deploy-shimmer" : ""}
|
||||
${deploy.isLockedChild ? "mol-deploy-locked" : ""}
|
||||
`}
|
||||
@@ -165,7 +165,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
/>
|
||||
|
||||
<div className="relative px-3.5 py-2.5">
|
||||
@@ -173,13 +173,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<span className="text-[13px] font-semibold text-zinc-100 truncate leading-tight">
|
||||
<span className="text-[13px] font-semibold text-ink truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasChildren && (
|
||||
<span className="text-[10px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1.5 py-0.5 rounded-md">
|
||||
<span className="text-[10px] font-mono text-accent bg-accent/15 border border-accent/40 px-1.5 py-0.5 rounded-md">
|
||||
{descendantCount} sub
|
||||
</span>
|
||||
)}
|
||||
@@ -207,13 +207,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{runtime === "external" ? (
|
||||
<span
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-violet-200 bg-violet-900/50 border border-violet-500/40"
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
|
||||
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
|
||||
>
|
||||
★ REMOTE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-zinc-400 bg-zinc-800/60 border border-zinc-700/30">
|
||||
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card border border-line">
|
||||
{runtime}
|
||||
</span>
|
||||
)}
|
||||
@@ -226,7 +226,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
* grow arbitrarily tall, which wrecks the grid-slot layout
|
||||
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight line-clamp-2">{data.role}</div>
|
||||
<div className="text-[10px] text-ink-mid mb-1.5 leading-tight line-clamp-2">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
@@ -237,15 +237,15 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
key={skill}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-md border ${
|
||||
isOnline
|
||||
? "text-emerald-300/80 bg-emerald-950/30 border-emerald-800/30"
|
||||
: "text-zinc-400 bg-zinc-800/60 border-zinc-700/40"
|
||||
? "text-good bg-good/15 border-good/40"
|
||||
: "text-ink-mid bg-surface-card border-line"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 4 && (
|
||||
<span className="text-[10px] text-zinc-500 self-center">
|
||||
<span className="text-[10px] text-ink-mid self-center">
|
||||
+{skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
@@ -261,7 +261,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1.5 mt-1 bg-amber-950/20 px-2 py-1 rounded-md border border-amber-800/20 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300/80 truncate">{data.currentTask}</span>
|
||||
<span className="text-[10px] text-warm/80 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -274,10 +274,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="flex items-center gap-1.5 mt-1 w-full bg-sky-950/30 px-2 py-1 rounded-md border border-sky-800/30 hover:bg-sky-900/40 transition-colors text-left focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none"
|
||||
className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-[10px]">↻</span>
|
||||
<span className="text-[10px] text-sky-300/80">Restart to apply changes</span>
|
||||
<span className="text-[10px] text-accent">↻</span>
|
||||
<span className="text-[10px] text-accent">Restart to apply changes</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -285,10 +285,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{data.status !== "online" ? (
|
||||
<div className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-300" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</div>
|
||||
@@ -296,8 +296,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300/80 tabular-nums">
|
||||
<div className="w-1 h-1 rounded-full bg-warm motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-warm tabular-nums">
|
||||
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -307,7 +307,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{/* Degraded error preview */}
|
||||
{data.status === "degraded" && data.lastSampleError && (
|
||||
<div
|
||||
className="text-[10px] text-amber-300/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
|
||||
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
|
||||
title={data.lastSampleError}
|
||||
>
|
||||
{data.lastSampleError}
|
||||
@@ -318,7 +318,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -357,7 +357,7 @@ function TeamMemberChip({
|
||||
}) {
|
||||
const { data } = node;
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
const isOnline = data.status === "online";
|
||||
const skills = getSkillNames(data.agentCard);
|
||||
|
||||
@@ -376,7 +376,7 @@ function TeamMemberChip({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${data.name}`}
|
||||
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
className="group/child relative rounded-lg bg-surface-card/60 hover:bg-surface-card/70 border border-line/30 hover:border-line/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(node.id);
|
||||
@@ -402,13 +402,13 @@ function TeamMemberChip({
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
|
||||
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
|
||||
<span className="text-[10px] font-semibold text-ink truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
<span className="text-[7px] font-mono text-accent bg-accent/15 border border-accent/40 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -423,7 +423,7 @@ function TeamMemberChip({
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
|
||||
className="opacity-0 group-hover/child:opacity-100 text-ink-mid hover:text-accent transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
|
||||
>
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
@@ -432,7 +432,7 @@ function TeamMemberChip({
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
|
||||
<div className="text-[10px] text-ink-mid mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
@@ -443,15 +443,15 @@ function TeamMemberChip({
|
||||
key={skill}
|
||||
className={`text-[10px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
|
||||
? "text-good bg-good/15 border-good/40"
|
||||
: "text-ink-mid bg-surface-card border-line"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 3 && (
|
||||
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
|
||||
<span className="text-[10px] text-ink-mid self-center">+{skills.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -460,10 +460,10 @@ function TeamMemberChip({
|
||||
<div className="flex items-center justify-between">
|
||||
{data.status !== "online" ? (
|
||||
<span className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-red-400" :
|
||||
data.status === "degraded" ? "text-amber-300" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-zinc-500"
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
@@ -471,7 +471,7 @@ function TeamMemberChip({
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300 tabular-nums">
|
||||
<span className="text-[10px] text-warm tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
</div>
|
||||
@@ -483,15 +483,15 @@ function TeamMemberChip({
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
|
||||
<span className="text-[10px] text-warm truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Recursive sub-children rendered inside this card */}
|
||||
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
|
||||
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
|
||||
<div className="mt-1.5 pt-1.5 border-t border-line/20">
|
||||
<div className="text-[10px] text-ink-mid uppercase tracking-widest mb-1">Team</div>
|
||||
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
|
||||
{subChildren.map((sub) => (
|
||||
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
|
||||
|
||||
@@ -46,16 +46,16 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 p-3 space-y-2"
|
||||
className="rounded-md border border-line bg-surface-sunken p-3 space-y-2"
|
||||
data-testid="workspace-usage"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<h4 className="text-xs font-semibold text-ink-mid uppercase tracking-wider">
|
||||
Usage
|
||||
</h4>
|
||||
{!loading && metrics && (
|
||||
<span
|
||||
className="text-[10px] text-zinc-600 font-mono"
|
||||
className="text-[10px] text-ink-soft font-mono"
|
||||
data-testid="usage-period"
|
||||
>
|
||||
{formatPeriod(metrics.period_start, metrics.period_end)}
|
||||
@@ -71,7 +71,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||||
<SkeletonRow />
|
||||
</>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-400" data-testid="usage-error">
|
||||
<p className="text-xs text-bad" data-testid="usage-error">
|
||||
{error}
|
||||
</p>
|
||||
) : metrics ? (
|
||||
@@ -114,8 +114,8 @@ function SkeletonRow() {
|
||||
className="flex justify-between items-center animate-pulse"
|
||||
data-testid="usage-skeleton-row"
|
||||
>
|
||||
<div className="h-3 w-20 rounded bg-zinc-700" />
|
||||
<div className="h-3 w-16 rounded bg-zinc-700" />
|
||||
<div className="h-3 w-20 rounded bg-surface-card" />
|
||||
<div className="h-3 w-16 rounded bg-surface-card" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,8 +131,8 @@ function StatRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center" data-testid={testId}>
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className="text-xs text-zinc-400 font-mono">{value}</span>
|
||||
<span className="text-xs text-ink-soft">{label}</span>
|
||||
<span className="text-xs text-ink-mid font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,4 +296,75 @@ describe("A2ATopologyOverlay component", () => {
|
||||
// setA2AEdges should still be called with an empty array
|
||||
expect(mockStoreState.setA2AEdges).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression for the 2026-05-04 render-loop incident:
|
||||
// tenant heartbeats / status flips / peer-discovery writes mutated
|
||||
// canvas store .nodes ~5x/sec. Previously visibleIds was useMemo'd on
|
||||
// [nodes] so the array reference recreated on every store mutation,
|
||||
// causing fetchAndUpdate to recreate, the useEffect to re-fire, and
|
||||
// the 60-second polling fan-out to fire on EVERY store update. With
|
||||
// 5 visible workspaces and 5 store updates/sec, the canvas hammered
|
||||
// /workspaces/<id>/activity?type=delegation 25×/sec until edge rate
|
||||
// -limit returned 429 (per browser console captured by user).
|
||||
//
|
||||
// Fix: select a stable string key (sorted CSV of IDs) from Zustand
|
||||
// so the selector's shallow-equal short-circuit prevents re-renders
|
||||
// when the actual ID set hasn't changed.
|
||||
//
|
||||
// This test verifies the fetch fires ONCE on mount + only re-fires
|
||||
// when the visible ID set actually changes, NOT on every nodes[]
|
||||
// reference change.
|
||||
it("does not re-fetch when nodes[] reference changes but visible IDs are the same", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
const { rerender } = render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
const callsAfterMount = mockGet.mock.calls.length;
|
||||
// Sanity: 2 visible nodes (ws-a, ws-b) → 2 fan-out requests on mount
|
||||
expect(callsAfterMount).toBe(2);
|
||||
|
||||
// Simulate a store mutation that changes the nodes array reference
|
||||
// (e.g. status flip on a node) WITHOUT changing the set of visible
|
||||
// IDs. Pre-fix: this triggered a re-fetch storm. Post-fix: the
|
||||
// sorted-CSV selector returns the same key, Zustand's shallow-equal
|
||||
// short-circuits, useMemo keeps the same visibleIds, fetchAndUpdate
|
||||
// keeps the same identity, useEffect does NOT re-fire.
|
||||
mockStoreState.nodes = [
|
||||
{ id: "ws-a", hidden: false, data: { newStatus: "online" } }, // mutated
|
||||
{ id: "ws-b", hidden: false, data: {} },
|
||||
{ id: "ws-hidden", hidden: true, data: {} },
|
||||
];
|
||||
rerender(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
// No additional fetches should have fired.
|
||||
expect(mockGet.mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
|
||||
it("re-fetches when the visible ID set actually changes", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
const { rerender } = render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
const callsAfterMount = mockGet.mock.calls.length;
|
||||
expect(callsAfterMount).toBe(2);
|
||||
|
||||
// Add a new visible workspace — the visible-ID-set actually changed.
|
||||
mockStoreState.nodes = [
|
||||
{ id: "ws-a", hidden: false, data: {} },
|
||||
{ id: "ws-b", hidden: false, data: {} },
|
||||
{ id: "ws-c", hidden: false, data: {} }, // NEW
|
||||
{ id: "ws-hidden", hidden: true, data: {} },
|
||||
];
|
||||
rerender(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
// Should have fetched the additional workspace + the existing two
|
||||
// (the effect re-fires once with the new ID set). Total: 2 + 3 = 5.
|
||||
expect(mockGet.mock.calls.length).toBe(callsAfterMount + 3);
|
||||
const allPaths = mockGet.mock.calls.map(([p]) => p as string);
|
||||
expect(allPaths.some((p) => p.includes("ws-c"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("AuthGate — loading state", () => {
|
||||
</AuthGate>
|
||||
);
|
||||
|
||||
const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0");
|
||||
const overlay = container.querySelector(".bg-surface.fixed.inset-0");
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
@@ -130,6 +130,26 @@ describe("BatchActionBar", () => {
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
|
||||
});
|
||||
|
||||
it("Esc clears the selection — matches the deselect button title", () => {
|
||||
// The deselect button has been promising "Clear selection (Escape)"
|
||||
// since the bar shipped, but no handler was wired. This pins the
|
||||
// contract.
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockClearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Esc is a no-op when nothing is selected", () => {
|
||||
mockSelectedNodeIds = new Set<string>();
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
// The early-return at count===0 prevents the bar from mounting at all,
|
||||
// so the keydown listener never registers. clearSelection must NOT be
|
||||
// called.
|
||||
expect(mockClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* CommunicationOverlay tests — pin the rate-limit fix shipped 2026-05-04.
|
||||
*
|
||||
* The overlay polls /workspaces/:id/activity?limit=5 for each online
|
||||
* workspace. Pre-fix it (a) polled regardless of visibility and (b)
|
||||
* fanned out to 6 workspaces every 10s. With 8+ workspaces a user
|
||||
* triggered sustained 429s (server-side rate limit is 600 req/min/IP).
|
||||
*
|
||||
* These tests pin:
|
||||
* 1. Fan-out cap of 3 — even with 6 online nodes, only 3 fetches
|
||||
* 2. Visibility gate — when collapsed, no polling
|
||||
*
|
||||
* If a future refactor pushes either dial back up, CI fails before
|
||||
* the regression hits a paying tenant.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent } from "@testing-library/react";
|
||||
|
||||
// ── Mocks (hoisted before imports) ────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
// Six online nodes — enough to verify the cap of 3.
|
||||
const mockStoreState = {
|
||||
selectedNodeId: null as string | null,
|
||||
nodes: [
|
||||
{ id: "ws-1", data: { status: "online", name: "ws-1" } },
|
||||
{ id: "ws-2", data: { status: "online", name: "ws-2" } },
|
||||
{ id: "ws-3", data: { status: "online", name: "ws-3" } },
|
||||
{ id: "ws-4", data: { status: "online", name: "ws-4" } },
|
||||
{ id: "ws-5", data: { status: "online", name: "ws-5" } },
|
||||
{ id: "ws-6", data: { status: "online", name: "ws-6" } },
|
||||
{ id: "ws-offline", data: { status: "offline", name: "off" } },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn(
|
||||
(selector: (s: typeof mockStoreState) => unknown) =>
|
||||
selector(mockStoreState)
|
||||
),
|
||||
}));
|
||||
|
||||
// design-tokens has named exports — keep the shape minimal.
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
COMM_TYPE_LABELS: {
|
||||
a2a_send: "→",
|
||||
a2a_receive: "←",
|
||||
task_update: "✓",
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { CommunicationOverlay } from "../CommunicationOverlay";
|
||||
|
||||
const mockGet = vi.mocked(api.get);
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGet.mockReset();
|
||||
mockGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CommunicationOverlay — fan-out cap", () => {
|
||||
it("polls at most 3 of 6 online workspaces (rate-limit floor)", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
// Mount fires the first poll synchronously (no interval tick yet).
|
||||
// Pre-fix: 6 calls. Post-fix: 3.
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
// Verify the calls are for the FIRST 3 online nodes (slice order).
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/activity?limit=5");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-2/activity?limit=5");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-3/activity?limit=5");
|
||||
});
|
||||
|
||||
it("never polls offline workspaces", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
expect(mockGet).not.toHaveBeenCalledWith(
|
||||
"/workspaces/ws-offline/activity?limit=5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommunicationOverlay — cadence", () => {
|
||||
it("uses 30s interval cadence (was 10s pre-fix)", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // initial mount poll
|
||||
|
||||
// Advance 10s — pre-fix this would fire another poll. Post-fix: silent.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Advance to 30s — interval fires.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(6); // +3 from second tick
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommunicationOverlay — visibility gate", () => {
|
||||
// The visibility gate is the dial that drops collapsed-panel polling
|
||||
// to ZERO. The cadence test above can't catch its removal — if a
|
||||
// refactor dropped `if (!visible) return`, the cadence test would
|
||||
// still pass because the effect would still fire every 30s.
|
||||
//
|
||||
// Direct probe: render with comms-returning mock so the panel
|
||||
// actually renders (close button only exists in the expanded panel,
|
||||
// not the collapsed button-state). Click close, advance the clock,
|
||||
// assert no further fetches.
|
||||
it("stops polling after the user collapses the panel", async () => {
|
||||
// Mock returns one a2a_send so comms.length > 0 → panel renders →
|
||||
// close button accessible.
|
||||
mockGet.mockResolvedValue([
|
||||
{
|
||||
id: "act-1",
|
||||
workspace_id: "ws-1",
|
||||
activity_type: "a2a_send",
|
||||
source_id: "ws-1",
|
||||
target_id: "ws-2",
|
||||
summary: "test",
|
||||
status: "completed",
|
||||
duration_ms: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByLabelText } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
// Drain pending microtasks (resolves the await in fetchComms) so
|
||||
// setComms lands and the panel renders. Don't advance time — that
|
||||
// would fire the next interval tick and pollute the assertion.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Initial mount polled 3 workspaces.
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
mockGet.mockClear();
|
||||
|
||||
// Click the close button. Synchronous getByLabelText avoids
|
||||
// findBy's internal setTimeout (deadlocks under useFakeTimers).
|
||||
const closeBtn = getByLabelText("Close communications panel");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn);
|
||||
});
|
||||
|
||||
// Advance well past the 30s cadence — gate should suppress the tick.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
||||
describe("CookieConsent", () => {
|
||||
it("renders the banner when no decision is stored", () => {
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Accept all" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Necessary only" })).toBeTruthy();
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe("CookieConsent", () => {
|
||||
it("stores 'accepted' and hides the banner when user clicks Accept all", () => {
|
||||
render(<CookieConsent />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
@@ -61,7 +61,7 @@ describe("CookieConsent", () => {
|
||||
it("stores 'rejected' and hides the banner when user clicks Necessary only", () => {
|
||||
render(<CookieConsent />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Necessary only" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(parsed.decision).toBe("rejected");
|
||||
@@ -73,7 +73,7 @@ describe("CookieConsent", () => {
|
||||
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }),
|
||||
);
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-prompts when the stored decision is on an older policy version", () => {
|
||||
@@ -82,13 +82,13 @@ describe("CookieConsent", () => {
|
||||
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }),
|
||||
);
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-prompts when localStorage contains invalid JSON", () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, "{not json");
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes a privacy-policy link with target='_blank'", () => {
|
||||
@@ -99,11 +99,19 @@ describe("CookieConsent", () => {
|
||||
expect(link.getAttribute("rel")).toContain("noreferrer");
|
||||
});
|
||||
|
||||
it("uses role=dialog with aria-labelledby and aria-describedby for screen readers", () => {
|
||||
it("uses role=region (NOT dialog) with aria-labelledby/describedby — banner is informational, not modal", () => {
|
||||
// Regression guard: an earlier version claimed role="dialog"
|
||||
// aria-modal="true" without a focus trap. That falsely told screen
|
||||
// readers the rest of the page was inert, trapping AT users in a
|
||||
// banner they couldn't escape. role="region" lets assistive tech
|
||||
// navigate around it normally; the banner stays informational.
|
||||
render(<CookieConsent />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
|
||||
expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body");
|
||||
const banner = screen.getByRole("region");
|
||||
expect(banner.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
|
||||
expect(banner.getAttribute("aria-describedby")).toBe("cookie-consent-body");
|
||||
// No aria-modal claim — explicit guard against regression.
|
||||
expect(banner.getAttribute("aria-modal")).toBeNull();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render on local dev (non-SaaS hostname)", () => {
|
||||
@@ -116,7 +124,7 @@ describe("CookieConsent", () => {
|
||||
value: { ...window.location, hostname: "localhost" },
|
||||
});
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render on a LAN hostname (192.168.*, *.local)", () => {
|
||||
@@ -125,7 +133,7 @@ describe("CookieConsent", () => {
|
||||
value: { ...window.location, hostname: "192.168.1.74" },
|
||||
});
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Provider→model cascade in the deploy modal.
|
||||
*
|
||||
* Original bug (2026-05-02 hongming Hermes Agent):
|
||||
* 1. Modal pre-fills MODEL with template default (e.g. MiniMax-M2.7-highspeed)
|
||||
* 2. Provider radio defaults to providers[0] (Anthropic) — wrong vendor
|
||||
* 3. ENV-VAR input shows ANTHROPIC_API_KEY
|
||||
* 4. User pastes a key, deploys
|
||||
* 5. Workspace boots with model=MiniMax + ANTHROPIC_API_KEY → adapter
|
||||
* crashes before /registry/register → WORKSPACE_PROVISION_FAILED.
|
||||
*
|
||||
* Fix: pre-deploy modal back-derives provider from initialModel and pins
|
||||
* the selector to the matching vendor. The dropdown UI (replacing the
|
||||
* old radios in PR shipped 2026-05-02) keeps the same invariant.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
import { MissingKeysModal, providerIdForModel } from "../MissingKeysModal";
|
||||
import { buildProviderCatalog } from "../ProviderModelSelector";
|
||||
import type { ModelSpec, ProviderChoice } from "@/lib/deploy-preflight";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: vi.fn(), put: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deploy-preflight", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/deploy-preflight")>(
|
||||
"@/lib/deploy-preflight",
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
// Hermes-shaped fixture: 3 providers, multiple models per provider, one
|
||||
// "no required_env" local model that should never block a deploy.
|
||||
const HERMES_PROVIDERS: ProviderChoice[] = [
|
||||
{
|
||||
id: "ANTHROPIC_API_KEY",
|
||||
label: "Anthropic (8 models)",
|
||||
envVars: ["ANTHROPIC_API_KEY"],
|
||||
},
|
||||
{
|
||||
id: "MINIMAX_API_KEY",
|
||||
label: "MiniMax (2 models)",
|
||||
envVars: ["MINIMAX_API_KEY"],
|
||||
},
|
||||
{
|
||||
id: "OPENROUTER_API_KEY",
|
||||
label: "OpenRouter (14 models)",
|
||||
envVars: ["OPENROUTER_API_KEY"],
|
||||
},
|
||||
];
|
||||
|
||||
const HERMES_MODELS: ModelSpec[] = [
|
||||
{ id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "claude-opus-4-7", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "MiniMax-M2.7-highspeed", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "MiniMax-M2.7", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "openrouter/anthropic/claude-3.5-sonnet", required_env: ["OPENROUTER_API_KEY"] },
|
||||
// Local/self-hosted endpoint — no required_env. Picker should
|
||||
// never snap on this one because there's no provider to snap to.
|
||||
{ id: "local-llama3", required_env: [] },
|
||||
];
|
||||
|
||||
/** Resolve the selector option-value for a given vendor against the
|
||||
* vendor-aware catalog. Catalog ids are `${vendor}|${sortedEnv}`, so
|
||||
* test code shouldn't hard-code them. */
|
||||
function providerIdForVendor(vendor: string): string {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const entry = catalog.find((p) => p.vendor === vendor);
|
||||
if (!entry) throw new Error(`vendor "${vendor}" not in catalog`);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
describe("providerIdForModel (legacy helper, still exported for tests)", () => {
|
||||
it("returns the provider id (sorted+joined required_env) for a known model", () => {
|
||||
expect(providerIdForModel("MiniMax-M2.7-highspeed", HERMES_MODELS)).toBe(
|
||||
"MINIMAX_API_KEY",
|
||||
);
|
||||
expect(providerIdForModel("claude-opus-4-7", HERMES_MODELS)).toBe(
|
||||
"ANTHROPIC_API_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts required_env so the id matches providersFromTemplate's formula", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "weird", required_env: ["Z_KEY", "A_KEY"] },
|
||||
];
|
||||
expect(providerIdForModel("weird", models)).toBe("A_KEY|Z_KEY");
|
||||
});
|
||||
|
||||
it("trims whitespace before lookup so a stray space doesn't miss a match", () => {
|
||||
expect(providerIdForModel(" MiniMax-M2.7 ", HERMES_MODELS)).toBe(
|
||||
"MINIMAX_API_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for empty / undefined / whitespace-only model id", () => {
|
||||
expect(providerIdForModel("", HERMES_MODELS)).toBeNull();
|
||||
expect(providerIdForModel(" ", HERMES_MODELS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when models are not provided (free-text mode)", () => {
|
||||
expect(providerIdForModel("anything", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when model isn't in the registry (free-text)", () => {
|
||||
expect(providerIdForModel("not-a-listed-model", HERMES_MODELS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the model has no required_env (local endpoint)", () => {
|
||||
expect(providerIdForModel("local-llama3", HERMES_MODELS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderPickerModal — model→provider cascade (dropdown UI)", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// The headline bug: opening the modal with the MiniMax default
|
||||
// pre-filled should NOT leave the selector on Anthropic just because
|
||||
// Anthropic was first in providers[]. Back-derivation snaps it on
|
||||
// first paint to the MiniMax vendor entry.
|
||||
it("snaps provider selector to MiniMax when initialModel is a MiniMax model", () => {
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
|
||||
models={HERMES_MODELS}
|
||||
initialModel="MiniMax-M2.7-highspeed"
|
||||
onKeysAdded={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect.value).toBe(providerIdForVendor("minimax"));
|
||||
// The env-var input underneath should be for MINIMAX_API_KEY,
|
||||
// not ANTHROPIC_API_KEY — that's the load-bearing UX win. The
|
||||
// entry uses a password input with a fixed "sk-..." placeholder
|
||||
// when the key name contains "API_KEY"; assert exactly ONE such
|
||||
// input exists, which proves only the selected provider's envVars
|
||||
// were rendered into entries[].
|
||||
const apiKeyInputs = screen.getAllByPlaceholderText("sk-...");
|
||||
expect(apiKeyInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Mid-flow change: user starts with the pre-filled MiniMax model and
|
||||
// switches the provider dropdown to Anthropic. Env-var rows below
|
||||
// re-render to show ANTHROPIC_API_KEY only. Same shape-pin as above.
|
||||
it("re-renders credential entries when provider is switched", () => {
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
|
||||
models={HERMES_MODELS}
|
||||
initialModel="MiniMax-M2.7-highspeed"
|
||||
onKeysAdded={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
fireEvent.change(providerSelect, {
|
||||
target: { value: providerIdForVendor("anthropic") },
|
||||
});
|
||||
expect(providerSelect.value).toBe(providerIdForVendor("anthropic"));
|
||||
// Exactly one password input means only the selected provider's
|
||||
// envVars landed in entries[].
|
||||
expect(screen.getAllByPlaceholderText("sk-...")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Backwards-compat: callers that don't pass `models` (legacy
|
||||
// call sites) fall back to a synthesized catalog from `providers`
|
||||
// — selector still works, but vendor split is degraded to env-tuple
|
||||
// grouping (one entry per ProviderChoice).
|
||||
it("falls back to providers[] when models prop is omitted", () => {
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
|
||||
// models intentionally omitted — legacy caller shape.
|
||||
onKeysAdded={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Without `models`, no back-derivation: selector defaults to
|
||||
// providers[0] (Anthropic). Dropdown still populated with all 3
|
||||
// entries — synthesized catalog uses `${vendor}|${envTuple}` ids
|
||||
// (matching the selector's own catalog shape), so the value is
|
||||
// "anthropic|ANTHROPIC_API_KEY", not the raw "ANTHROPIC_API_KEY".
|
||||
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect.value).toBe("anthropic|ANTHROPIC_API_KEY");
|
||||
expect(providerSelect.options.length).toBeGreaterThanOrEqual(4); // 3 providers + the disabled placeholder
|
||||
});
|
||||
|
||||
// configuredKeys interaction: when a provider's keys are already
|
||||
// saved globally, the picker pre-selects that satisfied provider.
|
||||
// BUT the model-derived snap still wins — the user explicitly
|
||||
// picked a model, that intent overrides "you already have this key".
|
||||
it("model-derived selection beats configuredKeys-satisfied default", () => {
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
// User has Anthropic globally. Without back-derivation,
|
||||
// selector would land on Anthropic. WITH it, the typed
|
||||
// MiniMax model wins.
|
||||
configuredKeys={new Set(["ANTHROPIC_API_KEY"])}
|
||||
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
|
||||
models={HERMES_MODELS}
|
||||
initialModel="MiniMax-M2.7-highspeed"
|
||||
onKeysAdded={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect.value).toBe(providerIdForVendor("minimax"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ProviderModelSelector — vendor detection + dropdown cascade.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
buildProviderCatalog,
|
||||
inferVendor,
|
||||
findProviderForModel,
|
||||
type SelectorModel,
|
||||
type SelectorValue,
|
||||
} from "../ProviderModelSelector";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// Fixture mirrors the real claude-code-default config.yaml — covers
|
||||
// the env-collision scenario (9 models share ANTHROPIC_AUTH_TOKEN
|
||||
// but represent 4 distinct vendors).
|
||||
const CLAUDE_CODE_MODELS: SelectorModel[] = [
|
||||
{ id: "sonnet", name: "Claude Sonnet (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "opus", name: "Claude Opus (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "haiku", name: "Claude Haiku (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (API)", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (API)", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "mimo-v2-flash", name: "Xiaomi MiMo Flash", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "mimo-v2-pro", name: "Xiaomi MiMo Pro", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "MiniMax-M2", name: "MiniMax M2", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
{ id: "GLM-4.6", name: "Z.ai GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
{ id: "kimi-k2", name: "Moonshot Kimi K2", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
];
|
||||
|
||||
const HERMES_MODELS: SelectorModel[] = [
|
||||
{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] },
|
||||
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet (direct)", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "openai/gpt-5", name: "GPT-5 via OR", required_env: ["OPENROUTER_API_KEY"] },
|
||||
{ id: "huggingface/*", name: "Any HF model", required_env: ["HF_TOKEN"] },
|
||||
{ id: "openrouter/*", name: "Any OpenRouter model", required_env: ["OPENROUTER_API_KEY"] },
|
||||
{ id: "custom/*", name: "Self-hosted endpoint", required_env: [] },
|
||||
];
|
||||
|
||||
describe("inferVendor", () => {
|
||||
it("uses slash prefix when present", () => {
|
||||
expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }))
|
||||
.toBe("nousresearch");
|
||||
expect(inferVendor({ id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] }))
|
||||
.toBe("anthropic");
|
||||
expect(inferVendor({ id: "openai/gpt-5", required_env: ["OPENROUTER_API_KEY"] }))
|
||||
.toBe("openai");
|
||||
});
|
||||
|
||||
it("infers vendor from bare-id pattern when no slash", () => {
|
||||
expect(inferVendor({ id: "MiniMax-M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("minimax");
|
||||
expect(inferVendor({ id: "GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("zai");
|
||||
expect(inferVendor({ id: "kimi-k2", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("moonshot");
|
||||
expect(inferVendor({ id: "deepseek-v4-pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("deepseek");
|
||||
expect(inferVendor({ id: "mimo-v2-flash", required_env: ["ANTHROPIC_API_KEY"] })).toBe("xiaomi-mimo");
|
||||
expect(inferVendor({ id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] })).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("treats bare sonnet/opus/haiku as anthropic-oauth ONLY when env demands OAuth", () => {
|
||||
expect(inferVendor({ id: "sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }))
|
||||
.toBe("anthropic-oauth");
|
||||
expect(inferVendor({ id: "opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }))
|
||||
.toBe("anthropic-oauth");
|
||||
// Hypothetical sonnet alias against API key — must NOT be tagged OAuth.
|
||||
expect(inferVendor({ id: "sonnet", required_env: ["ANTHROPIC_API_KEY"] }))
|
||||
.toBe("anthropic");
|
||||
});
|
||||
|
||||
it("falls back to env namespace for unknown vendors", () => {
|
||||
expect(inferVendor({ id: "unknown-id", required_env: ["OPENROUTER_API_KEY"] }))
|
||||
.toBe("openrouter");
|
||||
expect(inferVendor({ id: "unknown-id", required_env: ["HERMES_API_KEY"] }))
|
||||
.toBe("hermes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderCatalog", () => {
|
||||
it("splits ANTHROPIC_AUTH_TOKEN models by vendor (not just env)", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const vendors = catalog.map((p) => p.vendor).sort();
|
||||
// The 4 third-party vendors that share ANTHROPIC_AUTH_TOKEN must
|
||||
// all appear as separate entries.
|
||||
expect(vendors).toContain("minimax");
|
||||
expect(vendors).toContain("zai");
|
||||
expect(vendors).toContain("moonshot");
|
||||
expect(vendors).toContain("deepseek");
|
||||
// Plus the OAuth, Anthropic API, and Xiaomi MiMo entries.
|
||||
expect(vendors).toContain("anthropic-oauth");
|
||||
expect(vendors).toContain("anthropic");
|
||||
expect(vendors).toContain("xiaomi-mimo");
|
||||
});
|
||||
|
||||
it("buckets models under the correct vendor", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const minimax = catalog.find((p) => p.vendor === "minimax");
|
||||
expect(minimax).toBeDefined();
|
||||
expect(minimax!.models.map((m) => m.id).sort()).toEqual(["MiniMax-M2", "MiniMax-M2.7"]);
|
||||
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth");
|
||||
expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]);
|
||||
});
|
||||
|
||||
it("flags wildcard providers", () => {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const hf = catalog.find((p) => p.vendor === "huggingface");
|
||||
expect(hf?.wildcard).toBe(true);
|
||||
const custom = catalog.find((p) => p.vendor === "custom");
|
||||
expect(custom?.wildcard).toBe(true);
|
||||
const nous = catalog.find((p) => p.vendor === "nousresearch");
|
||||
expect(nous?.wildcard).toBe(false);
|
||||
});
|
||||
|
||||
it("decorates label with model count when ≥2 concrete models", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth");
|
||||
expect(oauth?.label).toMatch(/3 models/);
|
||||
// Wildcard buckets don't get the count suffix.
|
||||
const hfCatalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const hf = hfCatalog.find((p) => p.vendor === "huggingface");
|
||||
expect(hf?.label).not.toMatch(/models\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProviderForModel", () => {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
|
||||
it("matches concrete model ids directly", () => {
|
||||
expect(findProviderForModel(catalog, "nousresearch/hermes-4-70b")?.vendor)
|
||||
.toBe("nousresearch");
|
||||
expect(findProviderForModel(catalog, "openai/gpt-5")?.vendor).toBe("openai");
|
||||
});
|
||||
|
||||
it("matches wildcard providers by prefix", () => {
|
||||
expect(findProviderForModel(catalog, "huggingface/meta-llama/Meta-Llama-3-70B")?.vendor)
|
||||
.toBe("huggingface");
|
||||
expect(findProviderForModel(catalog, "openrouter/anthropic/claude-3.5-sonnet")?.vendor)
|
||||
.toBe("openrouter");
|
||||
expect(findProviderForModel(catalog, "custom/local-vllm")?.vendor).toBe("custom");
|
||||
});
|
||||
|
||||
it("returns null on no match", () => {
|
||||
expect(findProviderForModel(catalog, "")).toBeNull();
|
||||
expect(findProviderForModel(catalog, "unknown-model-xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Component behavior
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function setup(overrides?: Partial<{ value: SelectorValue; models: SelectorModel[]; onChange: (v: SelectorValue) => void }>) {
|
||||
const onChange = overrides?.onChange ?? vi.fn();
|
||||
const value: SelectorValue = overrides?.value ?? { providerId: "", model: "", envVars: [] };
|
||||
render(
|
||||
<ProviderModelSelector
|
||||
models={overrides?.models ?? CLAUDE_CODE_MODELS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe("<ProviderModelSelector>", () => {
|
||||
it("renders provider dropdown with all vendor options", () => {
|
||||
setup();
|
||||
const select = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
const optionTexts = Array.from(select.options).map((o) => o.text);
|
||||
expect(optionTexts).toContain("Claude Code subscription (3 models)");
|
||||
expect(optionTexts.some((t) => t.startsWith("MiniMax"))).toBe(true);
|
||||
expect(optionTexts.some((t) => t.startsWith("Z.ai"))).toBe(true);
|
||||
});
|
||||
|
||||
it("model dropdown is disabled until provider is picked", () => {
|
||||
setup();
|
||||
const modelSelect = screen.getByTestId("model-select") as HTMLSelectElement;
|
||||
expect(modelSelect.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("picking a multi-model provider emits onChange with empty model (forces explicit pick)", () => {
|
||||
const { onChange } = setup();
|
||||
const providerSelect = screen.getByTestId("provider-select");
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const minimax = catalog.find((p) => p.vendor === "minimax")!;
|
||||
// MiniMax bucket holds 2 models (MiniMax-M2 + MiniMax-M2.7). Auto-
|
||||
// picking the first one used to bite a real user (2026-05-03):
|
||||
// they wanted M2.7 but the silent default put M2 in the deploy
|
||||
// payload. Now the model field must come back empty so the next
|
||||
// dropdown is required-empty and Save/Deploy stay disabled until
|
||||
// the user picks.
|
||||
fireEvent.change(providerSelect, { target: { value: minimax.id } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
providerId: minimax.id,
|
||||
model: "",
|
||||
envVars: ["ANTHROPIC_AUTH_TOKEN"],
|
||||
});
|
||||
});
|
||||
|
||||
it("picking a single-model provider auto-fills the model (no choice to make)", () => {
|
||||
const { onChange } = setup();
|
||||
const providerSelect = screen.getByTestId("provider-select");
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
// GLM-4.6 is the only model under the zai vendor in the fixture —
|
||||
// a "0 vs many" boundary check. With only one option, forcing the
|
||||
// user to re-pick adds friction without preventing any error.
|
||||
const zai = catalog.find((p) => p.vendor === "zai")!;
|
||||
expect(zai.models.length).toBe(1);
|
||||
fireEvent.change(providerSelect, { target: { value: zai.id } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
providerId: zai.id,
|
||||
model: "GLM-4.6",
|
||||
envVars: ["ANTHROPIC_AUTH_TOKEN"],
|
||||
});
|
||||
});
|
||||
|
||||
it("picking provider then model emits combined value", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const minimax = catalog.find((p) => p.vendor === "minimax")!;
|
||||
const onChange = vi.fn();
|
||||
setup({
|
||||
value: { providerId: minimax.id, model: "MiniMax-M2", envVars: ["ANTHROPIC_AUTH_TOKEN"] },
|
||||
onChange,
|
||||
});
|
||||
const modelSelect = screen.getByTestId("model-select");
|
||||
fireEvent.change(modelSelect, { target: { value: "MiniMax-M2.7" } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
providerId: minimax.id,
|
||||
model: "MiniMax-M2.7",
|
||||
envVars: ["ANTHROPIC_AUTH_TOKEN"],
|
||||
});
|
||||
});
|
||||
|
||||
it("wildcard provider switches model UI to free-text input", () => {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const hf = catalog.find((p) => p.vendor === "huggingface")!;
|
||||
setup({
|
||||
models: HERMES_MODELS,
|
||||
value: { providerId: hf.id, model: "", envVars: hf.envVars },
|
||||
});
|
||||
expect(screen.queryByTestId("model-select")).toBeNull();
|
||||
expect(screen.queryByTestId("model-input")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("wildcard input emits typed value as model", () => {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const openrouter = catalog.find((p) => p.vendor === "openrouter")!;
|
||||
const onChange = vi.fn();
|
||||
setup({
|
||||
models: HERMES_MODELS,
|
||||
value: { providerId: openrouter.id, model: "", envVars: openrouter.envVars },
|
||||
onChange,
|
||||
});
|
||||
const input = screen.getByTestId("model-input");
|
||||
fireEvent.change(input, { target: { value: "openrouter/anthropic/claude-3.5-sonnet" } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
providerId: openrouter.id,
|
||||
model: "openrouter/anthropic/claude-3.5-sonnet",
|
||||
envVars: ["OPENROUTER_API_KEY"],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders required env hint for selected provider", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!;
|
||||
setup({
|
||||
value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars },
|
||||
});
|
||||
expect(screen.getByText(/requires:/).textContent).toMatch(/CLAUDE_CODE_OAUTH_TOKEN/);
|
||||
});
|
||||
|
||||
it("switching to a multi-model provider clears the stale model id", () => {
|
||||
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
|
||||
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!;
|
||||
const minimax = catalog.find((p) => p.vendor === "minimax")!;
|
||||
const onChange = vi.fn();
|
||||
setup({
|
||||
value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars },
|
||||
onChange,
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("provider-select"), { target: { value: minimax.id } });
|
||||
// Empty rather than auto-picked — see "picking a multi-model
|
||||
// provider …" test above for the user-facing rationale.
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
providerId: minimax.id,
|
||||
model: "",
|
||||
envVars: ["ANTHROPIC_AUTH_TOKEN"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -155,18 +155,31 @@ describe("SearchDialog — keyboard accessibility", () => {
|
||||
expect(selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("typing a new query resets focusedIndex to -1", () => {
|
||||
it("typing a query that matches auto-highlights the first result", () => {
|
||||
// Replaces the older "resets to -1" assertion. New behavior: a query
|
||||
// with at least one match pins the highlight to row 0 so Enter picks
|
||||
// a result instead of being a no-op. Empty-query case is covered by
|
||||
// "Enter at focusedIndex=-1 does not select anything" above.
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.change(input, { target: { value: "Alpha" } });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
// Enter on the auto-highlighted match should select it without
|
||||
// needing a manual ArrowDown first.
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("typing a query that matches NOTHING resets focusedIndex to -1", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
|
||||
// Verify selection before reset
|
||||
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
|
||||
// Change query — triggers the useEffect that resets focusedIndex
|
||||
fireEvent.change(input, { target: { value: "Alpha" } });
|
||||
// After reset all options must have aria-selected="false"
|
||||
screen.getAllByRole("option").forEach((opt) => {
|
||||
expect(opt.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "zzz-no-match" } });
|
||||
// No options remain, so nothing to assert on aria-selected directly —
|
||||
// the empty-state message takes over. But Enter should be a no-op.
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aria-activedescendant matches the focused option's id", () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { Toaster, showToast } from "../Toaster";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Toaster keyboard a11y", () => {
|
||||
it("Esc dismisses the most recent toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("first", "info");
|
||||
showToast("second", "info");
|
||||
});
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("second")).toBeNull();
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Esc dismisses persistent error toasts", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("boom", "error");
|
||||
});
|
||||
expect(screen.getByText("boom")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("boom")).toBeNull();
|
||||
});
|
||||
|
||||
it("Esc with no toasts is a no-op", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
// no throw, nothing rendered
|
||||
expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss button has accessible label and is keyboard reachable", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("hi", "info");
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: "Dismiss notification" });
|
||||
expect(btn).toBeTruthy();
|
||||
// Native <button> defaults to keyboard-focusable; explicit assertion guards
|
||||
// against a future regression where someone adds tabindex=-1.
|
||||
expect(btn.getAttribute("tabindex")).not.toBe("-1");
|
||||
});
|
||||
|
||||
it("dismiss button click removes that specific toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("a", "info");
|
||||
showToast("b", "info");
|
||||
});
|
||||
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// Click the first dismiss → "a" goes away, "b" stays
|
||||
act(() => {
|
||||
fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(screen.queryByText("a")).toBeNull();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ function A2AEdgeImpl({
|
||||
// The edge stroke color matches what buildA2AEdges sets on the SVG
|
||||
// path style. Mirror it on the badge border so the visual identity
|
||||
// (hot=violet vs warm=blue) carries to the clickable label.
|
||||
const accent = isHot ? "border-violet-500/60" : "border-blue-500/60";
|
||||
const accent = isHot ? "border-violet-500/60" : "border-accent/60";
|
||||
const accentText = isHot ? "text-violet-200" : "text-blue-200";
|
||||
const ariaLabel = `${count} delegation${count === 1 ? "" : "s"} from ${
|
||||
edgeData.label?.split(" · ")[1] ?? "recent"
|
||||
@@ -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-zinc-900/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-zinc-800 hover:border-opacity-100 transition-colors cursor-pointer`}
|
||||
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>
|
||||
|
||||
@@ -112,10 +112,10 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
|
||||
if (confirming) {
|
||||
return (
|
||||
<div
|
||||
className="nodrag absolute -top-10 right-0 z-20 flex items-center gap-1.5 rounded-lg bg-zinc-900/95 px-2 py-1 shadow-lg border border-red-800/60"
|
||||
className="nodrag absolute -top-10 right-0 z-20 flex items-center gap-1.5 rounded-lg bg-surface-sunken/95 px-2 py-1 shadow-lg border border-red-800/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-[10px] text-zinc-300">
|
||||
<span className="text-[10px] text-ink-mid">
|
||||
Delete {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}?
|
||||
</span>
|
||||
<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-zinc-700/80 hover:bg-zinc-600 text-[10px] text-zinc-200"
|
||||
className="px-2 py-0.5 rounded bg-surface-card/80 hover:bg-surface-card text-[10px] text-ink"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
|
||||
@@ -168,7 +168,10 @@ describe("A2AEdge — render", () => {
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.className).toContain("border-blue-500/60");
|
||||
// Warm-paper migration: blue-500 border was mapped to the semantic
|
||||
// accent token; the text-blue-200 literal is intentionally retained
|
||||
// because tinted-state pill text reads in both themes.
|
||||
expect(btn.className).toContain("border-accent/60");
|
||||
expect(btn.className).toContain("text-blue-200");
|
||||
});
|
||||
|
||||
|
||||
@@ -105,11 +105,11 @@ export function OrgTokensTab() {
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">
|
||||
<h3 className="text-sm font-semibold text-ink">
|
||||
Organization API Keys
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 leading-relaxed">
|
||||
<p className="text-[10px] text-ink-soft leading-relaxed">
|
||||
Full-admin bearer tokens for this organization. Use with external
|
||||
integrations, CLI tools, or AI agents that need to manage
|
||||
workspaces, settings, and secrets. Each key has the same
|
||||
@@ -126,12 +126,12 @@ export function OrgTokensTab() {
|
||||
placeholder="Label (e.g. zapier, my-ci)"
|
||||
maxLength={100}
|
||||
aria-label="Organization API key label"
|
||||
className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600"
|
||||
className="flex-1 text-[11px] bg-surface-sunken/60 border border-line/50 rounded px-2 py-1.5 text-ink placeholder-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-3 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
className="px-3 py-1.5 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 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
@@ -147,10 +147,10 @@ export function OrgTokensTab() {
|
||||
{newToken && (
|
||||
<div className="bg-emerald-950/30 border border-emerald-800/40 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-emerald-400 font-semibold uppercase tracking-wider">
|
||||
<span className="text-[10px] text-good font-semibold uppercase tracking-wider">
|
||||
{newTokenName ? `New Key: ${newTokenName}` : 'New Key Created'}
|
||||
</span>
|
||||
<span className="text-[9px] text-emerald-500/70">
|
||||
<span className="text-[9px] text-good/70">
|
||||
Copy now — it won't be shown again
|
||||
</span>
|
||||
</div>
|
||||
@@ -160,14 +160,14 @@ export function OrgTokensTab() {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-emerald-300 transition-colors"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -175,20 +175,20 @@ export function OrgTokensTab() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-red-400">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-zinc-500 text-xs">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
|
||||
<Spinner /> Loading keys...
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-zinc-500">No active keys</p>
|
||||
<p className="text-[10px] text-zinc-600 mt-1">
|
||||
<p className="text-xs text-ink-soft">No active keys</p>
|
||||
<p className="text-[10px] text-ink-soft mt-1">
|
||||
Create a key above to authenticate API calls to this organization.
|
||||
</p>
|
||||
</div>
|
||||
@@ -197,19 +197,19 @@ export function OrgTokensTab() {
|
||||
{tokens.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between bg-zinc-800/40 border border-zinc-700/30 rounded-lg px-3 py-2"
|
||||
className="flex items-center justify-between bg-surface-card/40 border border-line/30 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<code className="text-[11px] font-mono text-zinc-300 bg-zinc-900/60 px-1.5 py-0.5 rounded shrink-0">
|
||||
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded shrink-0">
|
||||
{t.prefix}...
|
||||
</code>
|
||||
<div className="flex flex-col min-w-0">
|
||||
{t.name && (
|
||||
<span className="text-[11px] text-zinc-200 truncate">
|
||||
<span className="text-[11px] text-ink truncate">
|
||||
{t.name}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-[9px] text-zinc-500 space-x-3">
|
||||
<div className="text-[9px] text-ink-soft space-x-3">
|
||||
<span>Created {formatAge(t.created_at)}</span>
|
||||
{t.last_used_at && (
|
||||
<span>Last used {formatAge(t.last_used_at)}</span>
|
||||
@@ -219,7 +219,7 @@ export function OrgTokensTab() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-red-400/70 hover:text-red-400 transition-colors px-2 py-1 shrink-0"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -80,15 +80,15 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-200">API Tokens</h3>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
||||
<p className="text-[10px] text-ink-soft mt-0.5">
|
||||
Bearer tokens for authenticating API calls to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-3 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
className="px-3 py-1.5 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 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{creating ? <><Spinner size="sm" /> Creating...</> : '+ New Token'}
|
||||
</button>
|
||||
@@ -98,8 +98,8 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
{newToken && (
|
||||
<div className="bg-emerald-950/30 border border-emerald-800/40 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-emerald-400 font-semibold uppercase tracking-wider">New Token Created</span>
|
||||
<span className="text-[9px] text-emerald-500/70">Copy now — it won't be shown again</span>
|
||||
<span className="text-[10px] text-good font-semibold uppercase tracking-wider">New Token Created</span>
|
||||
<span className="text-[9px] text-good/70">Copy now — it won't be shown again</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] text-emerald-200 bg-emerald-950/50 px-2 py-1.5 rounded font-mono break-all select-all">
|
||||
@@ -107,14 +107,14 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-emerald-300 transition-colors"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -122,20 +122,20 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-red-400">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-zinc-500 text-xs">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
|
||||
<Spinner /> Loading tokens...
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-zinc-500">No active tokens</p>
|
||||
<p className="text-[10px] text-zinc-600 mt-1">
|
||||
<p className="text-xs text-ink-soft">No active tokens</p>
|
||||
<p className="text-[10px] text-ink-soft mt-1">
|
||||
Create a token to authenticate API calls.
|
||||
</p>
|
||||
</div>
|
||||
@@ -144,13 +144,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
{tokens.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between bg-zinc-800/40 border border-zinc-700/30 rounded-lg px-3 py-2"
|
||||
className="flex items-center justify-between bg-surface-card/40 border border-line/30 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<code className="text-[11px] font-mono text-zinc-300 bg-zinc-900/60 px-1.5 py-0.5 rounded">
|
||||
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
|
||||
{t.prefix}...
|
||||
</code>
|
||||
<div className="text-[9px] text-zinc-500 space-x-3">
|
||||
<div className="text-[9px] text-ink-soft space-x-3">
|
||||
<span>Created {formatAge(t.created_at)}</span>
|
||||
{t.last_used_at && (
|
||||
<span>Last used {formatAge(t.last_used_at)}</span>
|
||||
@@ -159,7 +159,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-red-400/70 hover:text-red-400 transition-colors px-2 py-1"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -24,18 +24,18 @@ const FILTERS: { id: FilterType; label: string; icon: string }[] = [
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
||||
a2a_receive: { text: "text-blue-400", bg: "bg-blue-950/30", border: "border-blue-800/30" },
|
||||
a2a_receive: { text: "text-accent", bg: "bg-blue-950/30", border: "border-blue-800/30" },
|
||||
a2a_send: { text: "text-cyan-400", bg: "bg-cyan-950/30", border: "border-cyan-800/30" },
|
||||
task_update: { text: "text-amber-400", bg: "bg-amber-950/30", border: "border-amber-800/30" },
|
||||
task_update: { text: "text-warm", bg: "bg-amber-950/30", border: "border-amber-800/30" },
|
||||
skill_promotion: { text: "text-violet-300", bg: "bg-violet-950/30", border: "border-violet-800/30" },
|
||||
agent_log: { text: "text-zinc-400", bg: "bg-zinc-800/30", border: "border-zinc-700/30" },
|
||||
error: { text: "text-red-400", bg: "bg-red-950/30", border: "border-red-800/30" },
|
||||
agent_log: { text: "text-ink-mid", bg: "bg-surface-card/30", border: "border-line/30" },
|
||||
error: { text: "text-bad", bg: "bg-red-950/30", border: "border-red-800/30" },
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, { icon: string; color: string }> = {
|
||||
ok: { icon: "✓", color: "text-emerald-400" },
|
||||
error: { icon: "✕", color: "text-red-400" },
|
||||
timeout: { icon: "⏱", color: "text-amber-400" },
|
||||
ok: { icon: "✓", color: "text-good" },
|
||||
error: { icon: "✕", color: "text-bad" },
|
||||
timeout: { icon: "⏱", color: "text-warm" },
|
||||
};
|
||||
|
||||
export function ActivityTab({ workspaceId }: Props) {
|
||||
@@ -75,7 +75,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Filter bar */}
|
||||
<div className="px-3 pt-3 pb-2 border-b border-zinc-800/40">
|
||||
<div className="px-3 pt-3 pb-2 border-b border-line/40">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
@@ -84,8 +84,8 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
||||
filter === f.id
|
||||
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
|
||||
@@ -96,7 +96,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
autoRefresh ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500"
|
||||
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
|
||||
}`}
|
||||
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
||||
>
|
||||
@@ -104,20 +104,23 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTraceOpen(true)}
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-blue-300 border border-blue-800/30"
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
|
||||
title="View full conversation trace across all workspaces"
|
||||
>
|
||||
Full Trace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadActivities}
|
||||
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[11px] rounded text-zinc-300"
|
||||
// hover:bg-surface-card on top of itself was a no-op;
|
||||
// lift to surface-elevated + focus-visible ring.
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[11px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10px] text-zinc-500">
|
||||
<div className="mt-1.5 text-[10px] text-ink-soft">
|
||||
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,19 +128,19 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||
{loading && activities.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">Loading activity...</div>
|
||||
<div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && activities.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-zinc-600 text-xs">No activity recorded yet</div>
|
||||
<div className="text-zinc-700 text-[9px] mt-1">
|
||||
<div className="text-ink-soft text-xs">No activity recorded yet</div>
|
||||
<div className="text-ink-soft text-[9px] mt-1">
|
||||
Activity logs appear when agents communicate or perform tasks
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +187,7 @@ function ActivityRow({
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isError
|
||||
? "bg-red-950/20 border-red-900/30"
|
||||
: "bg-zinc-800/60 border-zinc-700/40"
|
||||
: "bg-surface-card/60 border-line/40"
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2">
|
||||
@@ -195,7 +198,7 @@ function ActivityRow({
|
||||
</span>
|
||||
|
||||
{entry.method && (
|
||||
<span className="text-[10px] font-mono text-zinc-300 truncate">
|
||||
<span className="text-[10px] font-mono text-ink-mid truncate">
|
||||
{entry.method}
|
||||
</span>
|
||||
)}
|
||||
@@ -205,23 +208,23 @@ function ActivityRow({
|
||||
</span>
|
||||
|
||||
{entry.duration_ms != null && (
|
||||
<span className="text-[8px] text-zinc-500 font-mono tabular-nums shrink-0">
|
||||
<span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
|
||||
{entry.duration_ms}ms
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[8px] text-zinc-500 shrink-0">
|
||||
<span className="text-[8px] text-ink-soft shrink-0">
|
||||
{formatTime(entry.created_at)}
|
||||
</span>
|
||||
|
||||
<span className="text-[9px] text-zinc-500">
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary — replace raw IDs with workspace names */}
|
||||
{entry.summary && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1 truncate">
|
||||
<div className="text-[10px] text-ink-mid mt-1 truncate">
|
||||
{entry.summary
|
||||
.replace(entry.source_id || "", resolveName(entry.source_id))
|
||||
.replace(entry.target_id || "", resolveName(entry.target_id))}
|
||||
@@ -236,9 +239,9 @@ function ActivityRow({
|
||||
{resolveName(entry.source_id)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-zinc-500">→</span>
|
||||
<span className="text-[9px] text-ink-soft">→</span>
|
||||
{entry.target_id && (
|
||||
<span className="text-[9px] text-blue-400/80 truncate max-w-[140px]" title={entry.target_id}>
|
||||
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
|
||||
{resolveName(entry.target_id)}
|
||||
</span>
|
||||
)}
|
||||
@@ -247,7 +250,7 @@ function ActivityRow({
|
||||
|
||||
{/* Error detail */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[9px] text-red-400/80 mt-1 truncate">
|
||||
<div className="text-[9px] text-bad/80 mt-1 truncate">
|
||||
{entry.error_detail}
|
||||
</div>
|
||||
)}
|
||||
@@ -255,7 +258,7 @@ function ActivityRow({
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-zinc-700/30 mt-1 pt-2">
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-line/30 mt-1 pt-2">
|
||||
{entry.source_id && (
|
||||
<Detail label="Source" value={`${resolveName(entry.source_id)} (${entry.source_id.slice(0, 8)})`} />
|
||||
)}
|
||||
@@ -278,7 +281,7 @@ function ActivityRow({
|
||||
{entry.response_body && (
|
||||
<JsonBlock label="Response" data={entry.response_body} />
|
||||
)}
|
||||
<div className="text-[8px] text-zinc-500 font-mono select-all">
|
||||
<div className="text-[8px] text-ink-soft font-mono select-all">
|
||||
ID: {entry.id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,10 +301,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
|
||||
const hint = inferA2AErrorHint(detail);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[8px] text-red-400/80 uppercase tracking-wider mb-1">{label} — delivery failed</div>
|
||||
<div className="text-[10px] text-red-300 bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
|
||||
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} — delivery failed</div>
|
||||
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
|
||||
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
|
||||
<div className="text-[9px] text-red-300/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
|
||||
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -326,8 +329,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{text.slice(0, 2000)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,8 +372,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{text.slice(0, 2000)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,8 +383,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
|
||||
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[8px] text-zinc-500 uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
||||
<span className={`text-[9px] break-all ${isError ? "text-red-400" : "text-zinc-300"} ${mono ? "font-mono" : ""}`}>
|
||||
<span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
||||
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
@@ -391,8 +394,8 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
|
||||
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
|
||||
<pre className="text-[9px] text-zinc-300 bg-zinc-900/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
||||
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -116,10 +116,10 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
<div className="space-y-3" data-testid="budget-section">
|
||||
{/* Section header */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<h3 className="text-xs font-semibold text-ink-mid uppercase tracking-wider">
|
||||
Budget
|
||||
</h3>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">
|
||||
<p className="text-[11px] text-ink-mid mt-0.5">
|
||||
Limit total message credits for this workspace
|
||||
</p>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="budget-exceeded-banner"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-950 border border-amber-700/50 text-amber-400 text-xs font-medium"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-amber-700/50 text-warm text-xs font-medium"
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
@@ -158,21 +158,21 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
|
||||
{/* Usage stats */}
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500" data-testid="budget-loading">
|
||||
<p className="text-xs text-ink-soft" data-testid="budget-loading">
|
||||
Loading…
|
||||
</p>
|
||||
) : fetchError ? (
|
||||
<p className="text-xs text-red-400" data-testid="budget-fetch-error">
|
||||
<p className="text-xs text-bad" data-testid="budget-fetch-error">
|
||||
{fetchError}
|
||||
</p>
|
||||
) : budget ? (
|
||||
<div className="space-y-2">
|
||||
{/* Stats row */}
|
||||
<div className="flex items-baseline justify-between" data-testid="budget-stats-row">
|
||||
<span className="text-xs text-zinc-400">Credits used</span>
|
||||
<span className="text-xs font-mono text-zinc-300">
|
||||
<span className="text-xs text-ink-mid">Credits used</span>
|
||||
<span className="text-xs font-mono text-ink-mid">
|
||||
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
|
||||
<span className="text-zinc-500 mx-1">/</span>
|
||||
<span className="text-ink-soft mx-1">/</span>
|
||||
<span data-testid="budget-limit-value">
|
||||
{budget.budget_limit != null
|
||||
? budget.budget_limit.toLocaleString()
|
||||
@@ -189,11 +189,11 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
aria-valuenow={progressPct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-zinc-800 overflow-hidden"
|
||||
className="h-1.5 w-full rounded-full bg-surface-card overflow-hidden"
|
||||
>
|
||||
<div
|
||||
data-testid="budget-progress-fill"
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
className="h-full rounded-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
|
||||
{/* Remaining credits */}
|
||||
{budget.budget_remaining != null && (
|
||||
<p className="text-[11px] text-zinc-500" data-testid="budget-remaining">
|
||||
<p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
|
||||
{budget.budget_remaining.toLocaleString()} credits remaining
|
||||
</p>
|
||||
)}
|
||||
@@ -212,7 +212,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<label
|
||||
htmlFor={`budget-limit-input-${workspaceId}`}
|
||||
className="text-[11px] text-zinc-400 block"
|
||||
className="text-[11px] text-ink-mid block"
|
||||
>
|
||||
Budget limit (credits)
|
||||
</label>
|
||||
@@ -225,15 +225,15 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
onChange={(e) => setLimitInput(e.target.value)}
|
||||
placeholder="e.g. 1000 — blank for unlimited"
|
||||
data-testid="budget-limit-input"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-300 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors"
|
||||
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Leave blank for unlimited</p>
|
||||
<p className="text-xs text-ink-soft">Leave blank for unlimited</p>
|
||||
|
||||
{saveError && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="budget-save-error"
|
||||
className="px-3 py-1.5 rounded-lg bg-red-950/40 border border-red-800/50 text-xs text-red-400"
|
||||
className="px-3 py-1.5 rounded-lg bg-red-950/40 border border-red-800/50 text-xs text-bad"
|
||||
>
|
||||
{saveError}
|
||||
</div>
|
||||
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
data-testid="budget-save-btn"
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
|
||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user