Compare commits

..

117 Commits

Author SHA1 Message Date
Hongming Wang 88ef70431e Merge pull request #2505 from Molecule-AI/staging
staging → main: auto-promote d64570a
2026-05-03 03:36:56 -07:00
Hongming Wang 750b32c33f Merge pull request #2558 from Molecule-AI/fix/a2a-v1-task-enqueue
fix(a2a): enqueue Task before TaskStatusUpdateEvent for v1 SDK contract
2026-05-03 10:18:36 +00:00
Hongming Wang 5c3b79a8ba fix(a2a): enqueue Task before TaskStatusUpdateEvent for v1 SDK contract
a2a-sdk ≥ 1.0 raises InvalidAgentResponseError when an executor publishes a
TaskStatusUpdateEvent (e.g. via TaskUpdater.start_work) before any Task
event for fresh requests. The framework only auto-creates the Task on
continuation messages (existing task_id resolves via task_manager.get_task);
new requests leave _task_created unset and the SDK validation at
a2a/server/agent_execution/active_task.py rejects the first status update.

PR #2170 migrated the executor surface to v1 but missed this contract. The
synthetic E2E gate caught it on every staging run since (~1 week silent
fail) with:

    {"jsonrpc":"2.0","id":"e2e-msg-1","error":{"code":-32603,
     "message":"Agent should enqueue Task before TaskStatusUpdateEvent
     event","data":null}}

The fix enqueues a Task(state=SUBMITTED) before the TaskUpdater is
constructed, gated on `context.current_task is None` so continuation
messages don't double-enqueue (which the SDK logs about but doesn't reject).

Tests:

  - test_first_event_is_task_for_new_request — pins the new-request path:
    first enqueue must be a Task with the expected id/context_id
  - test_no_task_enqueue_on_continuation — pins the continuation path: when
    context.current_task is set, the executor must NOT re-enqueue Task
  - conftest: stub Task / TaskStatus / TaskState in the mocked a2a.types
    module so the import inside the executor resolves under unit tests

google-adk adapter does not have this bug — its execute() only emits
Message events, not TaskStatusUpdateEvent. Its cancel() does emit one,
but cancel is rarely-invoked and out of scope for this fix.

Live verification path: this PR's merge → publish-runtime cascade → next
synth-E2E firing should go green at step "8/11 Sending A2A message to
parent — expecting agent response".
2026-05-03 03:15:54 -07:00
Hongming Wang e014d22ee9 Merge pull request #2557 from Molecule-AI/feat/sweep-aws-secrets-orphans
feat(ops): sweep orphan AWS Secrets Manager secrets
2026-05-03 09:48:59 +00:00
Hongming Wang 18c2bdbe68 Merge pull request #2529 from Molecule-AI/dependabot/pip/workspace/starlette-gte-1.0.0
chore(deps)(deps): update starlette requirement from >=0.38.0 to >=1.0.0 in /workspace
2026-05-03 09:42:15 +00:00
Hongming Wang 6f8f7932d2 feat(ops): add sweep-aws-secrets janitor — orphan tenant bootstrap secrets
CP's deprovision flow calls Secrets.DeleteSecret() (provisioner/ec2.go:806)
but only when the deprovision runs to completion. Crashed provisions and
incomplete teardowns leak the per-tenant `molecule/tenant/<org_id>/bootstrap`
secret. At ~$0.40/secret/month, ~45 leaked secrets surfaced as ~$19/month
on the AWS cost dashboard.

The tenant_resources audit table (mig 024) tracks four kinds today —
CloudflareTunnel, CloudflareDNS, EC2Instance, SecurityGroup — and the
existing reconciler doesn't catch Secrets Manager orphans. The proper fix
(KindSecretsManagerSecret + recorder hook + reconciler enumerator) is filed
as a follow-up controlplane issue. This sweeper is the immediate stopgap.

Parallel-shape to sweep-cf-tunnels.sh:
  - Hourly schedule offset (:30, between sweep-cf-orphans :15 and
    sweep-cf-tunnels :45) so the three janitors don't burst CP admin
    at the same minute.
  - 24h grace window — never deletes a secret younger than the
    provisioning roundtrip, so an in-flight provision can't be racemurdered.
  - MAX_DELETE_PCT=50 default (mirrors sweep-cf-orphans for durable
    resources; tenant secrets should track 1:1 with live tenants).
  - Same schedule-vs-dispatch hardening as the other janitors:
    schedule → hard-fail on missing secrets, dispatch → soft-skip.
  - 8-way xargs parallelism, dry-run by default, --execute to delete.

Requires a dedicated AWS_JANITOR_* IAM principal — the prod molecule-cp
principal lacks secretsmanager:ListSecrets (it only has scoped
Get/Create/Update/Delete). The workflow's verify-secrets step will hard-fail
on the first scheduled run until those secrets are configured, surfacing
the missing setup loudly rather than silently no-op'ing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:38:08 -07:00
Hongming Wang 15124527da Merge pull request #2276 from Molecule-AI/feat/layer1-runtime-digest-pinning
feat(provisioner): digest-pin runtime images via runtime_image_pins table (Layer 1 of #2272)
2026-05-03 09:32:54 +00:00
Hongming Wang e9a1ce3591 Merge pull request #2556 from Molecule-AI/fix/cascade-list-align-to-manifest
fix(publish-runtime): align cascade list to 4 supported runtimes
2026-05-03 09:32:36 +00:00
Hongming Wang 1bff419833 feat(provisioner): digest-pin workspace images via runtime_image_pins (#2272 layer 1)
Layer 1 of the runtime-rollout plan. Decouples publish from promotion by
giving operators a `runtime_image_pins` table the provisioner consults at
container-create time. No row = legacy `:latest` behavior; row present =
provisioner pulls `<base>@sha256:<digest>`. One bad publish no longer
breaks every workspace simultaneously.

Mechanics:

  - Migration 047: `runtime_image_pins` (template_name PK + sha256 digest +
    audit columns) and `workspaces.runtime_image_digest` (nullable, with
    partial index) for "show me workspaces still on the old digest" queries.
  - `resolveRuntimeImage` (handlers/runtime_image_pin.go): looks up the
    pin, returns `<base>@sha256:<digest>` on hit, "" on miss/error so the
    provisioner falls through to the legacy tag map. Availability over
    pinning — any DB error logs and returns "" rather than blocking the
    provision. `WORKSPACE_IMAGE_LOCAL_OVERRIDE=1` short-circuits the
    lookup so devs rebuilding template images locally see their fresh
    build.
  - `WorkspaceConfig.Image` carries the resolved value into the
    provisioner. `selectImage` honors it ahead of the runtime→tag map and
    falls back to DefaultImage on unknown runtime.
  - The existing `imageTagIsMoving` predicate (#215) already returns false
    on `@sha256:` form, so digest pins skip the force-pull path naturally.

Tests:

  - Handler-side (sqlmock): no-pin/db-error/with-pin/empty/unknown/local-
    override paths cover every branch of `resolveRuntimeImage`.
  - Provisioner-side: `selectImage` table covers explicit-image preference,
    runtime-map fallback, unknown-runtime → default, empty-config →
    default. Plus a struct-literal compile-time pin on `Image` so a future
    refactor can't silently drop the field.

Layer 2 (per-ring routing via `workspaces.runtime_image_digest`) and the
admin promote/rollback endpoint ride on top of this and ship separately.
2026-05-03 02:30:00 -07:00
Hongming Wang 24276b9458 fix(publish-runtime): align cascade list to 4 supported runtimes
The cascade `TEMPLATES` list in publish-runtime.yml had drifted from
manifest.json:

  Currently dispatches to: claude-code, langgraph, crewai, autogen,
                           deepagents, hermes, gemini-cli, openclaw
  manifest.json supports:  claude-code, hermes, openclaw, codex (after
                           PR #2536 pruned to 4 actively-supported)

Two consequences of the drift:

1. `codex` (added in PR #2512, supported in manifest) was never in the
   cascade — fresh runtime publishes did NOT trigger a codex template
   rebuild. Codex stayed pinned to whatever runtime version it last saw
   at its own image-build time.

2. langgraph/crewai/autogen/deepagents/gemini-cli — deprecated, no
   shipping images, no working A2A — were still receiving cascade
   dispatches. Wasted API calls and (worse) green CI on dead repos
   masks "this template is dead, stop maintaining it."

Now matches manifest.json workspace_templates exactly. Surfaced during
RFC #388 (fast workspace provision) prior-art audit.

Long-term fix is to derive TEMPLATES from manifest.json so this can't
drift again — captured as a Phase-1 invariant in RFC #388. This commit
is the data fix only; structural fix lands with the bake pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:28:15 -07:00
Hongming Wang aef9555b1d Merge pull request #2555 from Molecule-AI/feat/canvas-warm-paper-tailwind-v4
feat(canvas): warm-paper theme + Tailwind v4
2026-05-03 09:27:23 +00:00
Hongming Wang db48d1d261 fix(canvas): restore text-white on saturated buttons + close zinc gaps
Independent code review of #2555 caught two contrast regressions left
by the bulk perl pass:

1. text-white → text-ink mass-substitution silently broke destructive
   and primary buttons. text-ink resolves to #15181c (warm-paper
   near-black) in light mode — dark text on bg-red-600 / bg-amber-600
   / bg-emerald-600 / bg-blue-600 / bg-accent / bg-accent-strong /
   bg-good / bg-bad fails WCAG contrast and looks broken. Per-line
   pass flips text-ink → text-white only when a saturated bg utility
   is present; tinted-state pills (bg-red-950/50 etc.) keep their
   intentionally-retained text-* literals.

2. Original mapping table was missing bg-zinc-600 (most-used
   hover-state literal for cancel buttons — caused them to JUMP from
   warm cream resting state to dark zinc on hover in light mode) and
   text-zinc-700/800/900 (separator dots and decorative dim text
   invisible on warm-paper light bg). Extended mapping fills these
   gaps with bg-surface-card / text-ink-soft.

Also: drop stale tailwind.config.ts reference from components.json
(file deleted by the v3→v4 migration); switch baseColor zinc →
neutral and enable cssVariables since v4 uses CSS-driven tokens.
Future shadcn-cli invocations would have failed or written malformed
components without this.

27 sites in 27 files affected by #1, ~20 sites in 20 files by #2.
1214/1214 unit tests still pass; build still clean.

Findings courtesy of multi-model review per code-review-and-quality
skill — different blind spots catch different bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:04:20 -07:00
Hongming Wang 052575d773 fix(canvas): regenerate lockfile with cross-platform optional deps
CI's `npm ci` failed because the previous lock was generated on macOS
arm64, which omits the Linux-specific optional deps that
@tailwindcss/postcss → lightningcss-linux-x64-gnu transitively need
(@emnapi/runtime, @emnapi/core).

Re-ran `npm install --include=optional` so the lock includes every
platform variant of lightningcss + the @emnapi packages they pull in.
Runner (Linux x64) now has what it needs; local macOS install still
fine (npm picks the matching binary at install time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:52:42 -07:00
Hongming Wang c0eca8d0e1 feat(canvas): warm-paper theme + Tailwind v4 migration
Brings the canvas onto the warm-paper design system already shipped to
landing, marketplace, and SaaS surfaces, and migrates the build from
Tailwind v3 → v4 to match molecule-app.

Plumbing:
- swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss
- delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS)
- globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography"
- two @theme blocks: warm-paper light defaults + always-dark surface
  tokens (bg-bg / ink-mute / line-strong) for terminal/console panels
- [data-theme="dark"] cascade overrides the warm-paper tokens for dark
- React Flow edge stroke + scrollbar + selection colour pull from
  semantic tokens so they flip with the theme

Theme infra (ported from molecule-app, identical contracts):
- lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client"
  so server components can read the constants)
- lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with
  Domain=.moleculesai.app so the preference follows the user across
  canvas/app/market/landing subdomains AND tenant subdomains
- lib/theme.ts: ColorToken union + cssVar() helper
- components/ThemeToggle.tsx: 3-way System/Light/Dark picker
- layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs
  the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd
  inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body

Component migration (62 files):
- Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white →
  semantic surface/ink/line tokens via perl negative-lookahead pass
  (preserves opacity modifiers like /80, /60)
- bg-blue-500/600 → bg-accent / bg-accent-strong
- text-red-* / amber-* / emerald-* → text-bad / warm / good
- Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950
  etc.) intentionally left literal — they remain readable on warm-paper
  in light mode without inventing new state-soft tokens
- TerminalTab.tsx skipped — xterm renders to canvas, not DOM
- 3 unit-test assertions updated to match new token strings (credits
  pillTone, AuthGate overlay class, A2AEdge accent)

Verification:
- pnpm test: 1214/1214 pass
- pnpm tsc --noEmit: clean
- next build: ✓ Compiled successfully (8 routes)
- dev server inspection: html data-theme stamped, body uses
  bg-surface text-ink, boot script carries nonce, compiled CSS
  contains both @theme blocks + [data-theme="dark"] override

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:43:55 -07:00
Hongming Wang e4893f5a9a Merge pull request #2552 from Molecule-AI/feat/wire-event-log-into-adapter-base
feat(workspace): wire EventLog into adapter base (#119 PR-3b)
2026-05-03 08:39:34 +00:00
Hongming Wang 872f8e8971 Merge pull request #2554 from Molecule-AI/chore/remove-dead-ast-defensive-block
chore(workspace): remove dead defensive block in load_skills AST gate
2026-05-03 08:33:18 +00:00
Hongming Wang d58185b8a8 chore(workspace): remove dead defensive block in load_skills AST gate
Self-review of PR #2553 caught an unreachable defensive block at
test_load_skills_call_sites.py:99-103: the inner check guarded
`call.func.__class__.__name__ == "Name"` from a FunctionDef, but
`_find_load_skills_calls` already filters its return type to
`ast.Call` — `FunctionDef` cannot reach that loop body. The block
was a no-op `pass` with a misleading comment.

Removing keeps the gate behaviorally identical; tests still pass.

Same five-axis review pass that turned this up also approved the
substantive logic of #2553, so no behavior change here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:30:05 -07:00
Hongming Wang 3c0f7de4b9 Merge pull request #2553 from Molecule-AI/feat/skill-compat-audit
docs(skills): document SKILL.md `runtime` field + AST coverage gate (#119 PR-4)
2026-05-03 08:24:55 +00:00
Hongming Wang f8b40d8d73 docs(skills): document SKILL.md runtime field + AST coverage gate (#119 PR-4)
Closes the documentation + audit gap for declarative skill-compat. The
plumbing has been live since PR #117 (RuntimeCapabilities) and
skill_loader's `_normalize_runtime_field` has been emitting filter
decisions for weeks, but:
- No public doc explained the `runtime` frontmatter field, so skill
  authors didn't know how to opt in / opt out.
- No structural gate ensured every load_skills() call site threads
  current_runtime — a future caller forgetting the kwarg silently
  force-loads runtime-incompatible skills (no AttributeError, just a
  delayed crash on first tool invocation).

Two changes:

1. docs/agent-runtime/skills.md
   - Adds `runtime`, `tags`, `examples` to the Frontmatter Fields table.
   - Adds a Runtime Compatibility section with example, accepted shapes
     (universal default, list, string sugar), and the "logged + omitted,
     not crashed" failure mode. Notes that match values come from each
     adapter's name() (the same string in config.yaml's runtime: field).

2. workspace/tests/test_load_skills_call_sites.py
   - Static AST gate: walks every workspace/*.py (excluding tests),
     finds load_skills(...) Call nodes, fails if any lacks
     current_runtime= as a keyword.
   - Defense-in-depth `test_known_call_sites_present` — pins that the
     scan actually sees the two known callers (adapter_base,
     skill_loader.watcher) so a refactor that moves them is loud.
   - Sanity-checked the matcher against a synthetic violating module.

Same-shape pattern as PR #2358 (tenant_resources audit-coverage AST
gate, #150) — pin the contract structurally, not just behaviorally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:22:34 -07:00
Hongming Wang 71e7a6ffee feat(workspace): wire EventLog into adapter base (#119 PR-3b)
Adds adapter.event_log property+setter on BaseAdapter so adapters can
emit structured events (tool dispatch, skill load, executor errors)
without coupling to the chosen backend. Default is a shared no-op
DisabledEventLog; main.py overrides at boot from the
observability.event_log config block (PR-2 schema).

The shape is intentionally additive:
- Property is invisible to the BaseAdapter signature snapshot drift
  gate (the helper walks vars(cls) for callables only — properties
  are not callable). Verified with a regression test in the new
  test_adapter_base_event_log.py.
- Existing adapters continue to work unchanged. Template repos that
  never call self.event_log get the no-op for free.
- Setter accepts any EventLogBackend, so swapping memory↔disabled
  at runtime (or to a future Redis backend) requires no adapter
  code change.

Sequels:
- PR-3c: emit events from claude-code/hermes adapters at the
  natural points (tool dispatch, skill load).
- PR-4: skill-compat audit + SKILL.md frontmatter docs.
- Platform-side /workspaces/:id/activity endpoint reads the buffer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:18:19 -07:00
Hongming Wang e2b58f0fbc Merge pull request #2551 from Molecule-AI/feat/wire-observability-config
feat(workspace): wire observability heartbeat + log_level into consumers (#119 PR-3a)
2026-05-03 08:05:00 +00:00
Hongming Wang efa68a26b1 feat(workspace): wire observability config into heartbeat + uvicorn (#119 PR-3a)
Replaces the hard-coded HEARTBEAT_INTERVAL=30 in heartbeat.py and
log_level="info" in main.py with values from
ObservabilityConfig (#119 PR-1, schema landed in PR #2538).

Concrete plumbing:

  - heartbeat.HeartbeatLoop accepts an `interval_seconds=` keyword
    arg. Defaults to the legacy module constant so 2-arg callers
    (existing tests, any downstream code that hasn't been updated)
    keep their existing 30s behavior.
  - main.py constructs HeartbeatLoop with
    config.observability.heartbeat_interval_seconds — the value the
    config parser already clamped to [5, 300].
  - main.py's uvicorn.Config takes log_level from
    config.observability.log_level (lowercased — uvicorn's convention
    differs from Python logging's) with LOG_LEVEL env still winning
    as an ops-side debugging override.

Adapter EventLog wiring deferred to PR-3b (#208 follow-up) — touches
adapter_base interface + needs careful design, kept separate to keep
this PR small + reviewable.

Tests:
  - test_heartbeat.py: 3 new tests pin default interval, explicit
    override, and the [5, 300] band that the constructor accepts
    without re-clamping (clamping is the parser's job).
  - All 88 tests in test_heartbeat.py + test_config.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:01:57 -07:00
Hongming Wang 87e355c296 Merge pull request #2548 from Molecule-AI/feat/event-log-module
feat(workspace): event_log module + EventLogConfig (#119 PR-2)
2026-05-03 07:54:05 +00:00
Hongming Wang 67f3e49e42 Merge pull request #2549 from Molecule-AI/fix/orphan-sweeper-skip-external-runtime
fix(orphan-sweeper): exclude runtime='external' from stale-token revoke
2026-05-03 07:52:43 +00:00
Hongming Wang be271aef8b fix(orphan-sweeper): exclude runtime='external' from stale-token revoke
The Docker-mode orphan sweeper was incorrectly targeting external runtime
workspaces, revoking their auth tokens ~6 minutes after creation (one
sweep cycle past the 5-min grace).

External workspaces have NO local container by design — their agent runs
off-host. The "no live container" predicate the sweep uses to detect
wiped-volume orphans matches every external workspace unconditionally,
which was killing the only auth credential the off-host agent has.

Reproducer: create runtime=external workspace, paste the auth token into
molecule-mcp / curl, wait 5 minutes. Next request returns
`HTTP 401 — token may be revoked`. Platform log shows
`Orphan sweeper: revoking stale tokens for workspace <id> (no live
container; volume likely wiped)`.

Fix: add `AND w.runtime != 'external'` to the sweep's SELECT. The
existing test regexes (third-pass query expectations + the shared
expectStaleTokenSweepNoOp helper) are tightened to require the new
predicate, so a regression that drops it fails CI immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:49:37 -07:00
Hongming Wang 9753d58539 fix(build): register event_log in TOP_LEVEL_MODULES
The wheel-build drift gate caught it correctly: any new top-level
module under workspace/ must be listed in TOP_LEVEL_MODULES so its
`from event_log import …` statements get rewritten to
`from molecule_runtime.event_log import …` at package time.

Without this entry, the published wheel ships event_log.py un-rewritten
and crashes at runtime with ModuleNotFoundError on first heartbeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:19:30 -07:00
Hongming Wang 0fc2531250 feat(workspace): event_log module + EventLogConfig (#119 PR-2)
Adds workspace/event_log.py with an in-memory EventLog backend and a
disabled no-op variant, plus EventLogConfig nested in
ObservabilityConfig (backend / ttl_seconds / max_entries).

The event log is the append-and-query buffer that the canvas Activity
tab and platform `/activity` endpoint will read in PR-3 of the #119
stack. Two backends ship in this PR:

  - InMemoryEventLog: bounded ring buffer with TTL eviction, monotonic
    ids that survive eviction so cursors don't break, thread-safe for
    concurrent appends from heartbeat + main loop + A2A executor.
  - DisabledEventLog: no-op for `backend: disabled` — opts the
    workspace out without crashing callers that propagate event ids.

Schema-only PR — no consumers wired yet. Wiring lands in PR-3.

Test coverage:
  - 34 new test_event_log.py tests (100% line coverage on event_log.py)
  - 9 new test_config.py tests for EventLogConfig parsing
  - Concurrency stress with 8 threads × 200 appends — verifies unique
    monotonic ids under contention
  - TTL + max_entries eviction with injected clock (no time.sleep)
  - Disabled backend contract pinned

Closes #207.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:17:12 -07:00
Hongming Wang 350495f032 Merge pull request #2547 from Molecule-AI/perf/cache-platform-inbound-secret
perf(wsauth): in-process cache for platform_inbound_secret reads
2026-05-03 07:11:38 +00:00
Hongming Wang 384edb4af0 Merge branch 'staging' into perf/cache-platform-inbound-secret 2026-05-03 00:08:43 -07:00
Hongming Wang b040171fa1 perf(wsauth): in-process cache for platform_inbound_secret reads
Heartbeats fire every 60s per workspace and were the dominant caller
of ReadPlatformInboundSecret — one DB SELECT each, purely to redeliver
the same value. For an N-workspace fleet that's N SELECTs/minute of
pure overhead, growing linearly with the fleet (#189).

This adds a sync.Map cache keyed by workspaceID with a 5-minute TTL:

- **Read-through**: cache miss → DB SELECT → populate → return.
- **Write-through**: every IssuePlatformInboundSecret call refreshes
  the cache with the new value before returning, so the lazy-heal mint
  path (readOrLazyHealInboundSecret) doesn't see a stale read of the
  value it just wrote.
- **TTL eviction**: 5 minutes — generous enough that the heartbeat
  hot path hits cache for ~5 reads in a row before re-validating, short
  enough that an out-of-band rotation (operator running
  `UPDATE workspaces SET platform_inbound_secret=...` directly)
  propagates within minutes without requiring a redeploy.
- **Absence not cached**: ErrNoInboundSecret skips the cache write so
  the lazy-heal recovery contract for the column-NULL case
  (readOrLazyHealInboundSecret in workspace_provision_shared.go) keeps
  working.

Memory footprint is bounded by the active workspace fleet (~200 bytes
per entry); deleted workspaces leave dead entries until process restart,
acceptable given workspace-deletion is operator-rare.

Why in-process instead of Redis: workspace-server runs as a single
Railway service today (per memory project_controlplane_ownership);
adding Redis for this single column read would be over-engineering.
The cache is a self-contained, Redis-free upgrade that keeps the same
semantic surface (read returns the latest secret) while collapsing
the heartbeat read storm. If the deployment ever fans out across
replicas, an operator-side rotation propagates per-replica TTL-bounded
without needing a shared write log.

Tests: 5 new cases covering cache hit within TTL, refresh after TTL
(simulating an operator rotation via SQL), write-through on Issue,
absence-not-cached, and Reset clearing all entries. The setupMock
helper in wsauth and setupTestDB helper in handlers both call
ResetInboundSecretCacheForTesting() at start + cleanup so write-through
state from one test doesn't shadow SELECT expectations in the next.
SetInboundSecretCacheNowForTesting() exposes a deterministic clock
override so the TTL test doesn't sleep.

Task: #189.
2026-05-03 00:04:38 -07:00
Hongming Wang c4f64a11a8 Merge pull request #2546 from Molecule-AI/fix/provisioner-repull-moving-tags
fix(provisioner): force re-pull of moving image tags on workspace start
2026-05-03 06:59:36 +00:00
Hongming Wang 552602e462 fix(provisioner): force re-pull of moving image tags on workspace start
Previously Start() only pulled when the image was missing locally
(imgErr != nil). Once a tenant's Docker daemon had `:latest` cached,
it stuck on that snapshot forever even after publish-runtime pushed
a newer image with the same tag — the same image-cache class that
sibling task #232 closed on the controlplane redeploy path.

Now Start() additionally re-pulls when the tag is "moving"
(`:latest`, no tag, `:staging`, `:main`, `:dev`, `:edge`, `:nightly`,
`:rolling`). Pinned tags (semver, sha-prefixed, date-stamped, build-id)
and digest-pinned references (`@sha256:...`) skip the pull because
their contents are by definition immutable.

The classifier (imageTagIsMoving) is deliberately conservative on the
"moving" side — only the well-known moving tags trip it. Misclassifying
a pinned tag as moving wastes bandwidth on every provision; misclassifying
moving as pinned silently bricks the fleet on stale snapshots, which
is exactly the bug class this fix closes.

Edge cases handled:
- Registry hostname with port (`localhost:5000/foo`) — the `:5000` is
  not mistaken for a tag.
- Digest pinning (`image@sha256:...`) — never re-pulled even if a
  moving-looking tag is also present.
- Legacy local-build tags (`workspace-template:hermes`) — treated as
  pinned (no registry to move from).

Test coverage: 22 cases across all classifier shapes. No changes to
the pull-failure path (still best-effort, ContainerCreate still
surfaces the actionable "image not found" error if the pull failed
and the cache is also empty).

Task: #215. Companion to #232.
2026-05-02 23:56:32 -07:00
Hongming Wang 29261cee3d Merge pull request #2537 from Molecule-AI/test/derive-provider-drift-gate
Test: AST drift gate for derive-provider.sh ↔ Go port
2026-05-03 06:54:22 +00:00
Hongming Wang dfeefb0acc fix(workspace-server): vendor upstream derive-provider.sh + close 12-prefix drift
The drift gate's monorepoRoot walk-up looked for workspace-configs-templates/
which is gitignored locally and doesn't exist in this repo at all (the
canonical script lives in molecule-ai-workspace-template-hermes). Test
failed on CI from day one with "could not find monorepo root".

Two layered fixes in one PR:

1. Vendor upstream derive-provider.sh as testdata/ + drop monorepoRoot.
   The vendored copy has a header pointing operators at the upstream
   source and a one-line cp command for refresh. Test now reads two
   files (vendored shell + workspace_provision.go) via package-relative
   paths — Go test sets cwd to the package dir, so this is hermetic
   without any walk-up gymnastics.

2. Update the case-statement regex to match upstream's renamed variable
   (${_HERMES_MODEL} since v0.12.0, the resolved value of
   HERMES_INFERENCE_MODEL with a HERMES_DEFAULT_MODEL legacy fallback).
   Regex now accepts either spelling so a future rename fails loudly
   on the parser-sanity check rather than silently returning empty.

Vendoring upstream surfaced real drift the gate was designed to catch:
upstream v0.12.0 added 12 provider prefixes that deriveProviderFromModelSlug
didn't handle (xai/grok, bedrock/aws, tencent/tencent-tokenhub, gmi,
qwen-oauth, lmstudio/lm-studio, minimax-oauth, alibaba-coding-plan,
google-gemini-cli, openai-codex, copilot-acp, copilot). Without these,
Save+Restart on a workspace using one of those prefixes would persist
LLM_PROVIDER="" and the next boot would fall back to derive-provider.sh's
runtime *=auto branch — losing the user's explicit choice on every restart.

Added all 12 case clauses + 16 new table-driven test cases (covering
both canonical and aliased forms). Drift gate now passes; future
upstream additions will fail loudly with a "DRIFT: ..." message
pointing the engineer at the missing case.

Task: #242
2026-05-02 23:51:23 -07:00
Hongming Wang 284012a768 test(workspace-server): AST drift gate for derive-provider.sh ↔ Go port
PR #2535 added a Go port of derive-provider.sh
(deriveProviderFromModelSlug) so workspace-server can persist
LLM_PROVIDER into workspace_secrets at provision time. This created
two sources of truth — if a future PR adds a provider prefix to one
without the other, the platform's persisted LLM_PROVIDER silently
disagrees with what the container's derive-provider.sh produces at
boot, with no test going red.

This adds a hermetic drift gate that:

  1. Parses workspace-configs-templates/hermes/scripts/derive-provider.sh
     with regex (handling both single-line `pat/*) PROVIDER="x" ;;`
     clauses and multi-line conditional clauses) to build a
     map[prefix]provider.
  2. Walks workspace_provision.go's AST with go/ast, finds
     deriveProviderFromModelSlug, and extracts every case-clause
     prefix → return-string-literal pair.
  3. Cross-checks both directions and accepts only the two documented
     divergences (nousresearch/* and openai/* both → "openrouter" at
     provision time because derive-provider.sh's runtime-env checks
     aren't loaded yet) via a hardcoded acceptedDivergences map.
  4. Fails with an actionable message that names both files and
     suggests the exact fix (add the case OR add to divergence list
     with a comment).

Pattern: behavior-based AST gate from PR #2367 / memory feedback —
pin the invariant by what the function maps, not by what it's named.
Stdlib-only (go/ast, go/parser, go/token, regexp); no network, no DB,
no docker — reads two monorepo files in-process.

A second sanity-check test pins anchor prefixes the regex must find,
so a future shell-syntax change can't silently produce an empty map
and trivially pass the main gate.

Closes task #242.
2026-05-02 23:51:23 -07:00
Hongming Wang a28f905c5a Merge pull request #2545 from Molecule-AI/fix/configtab-single-source-of-truth-MODEL
fix(canvas): ConfigTab is single source of truth for tier/provider/model
2026-05-03 06:40:38 +00:00
Hongming Wang bdd1d09dfb fix(canvas): tighten originalModel + pin store-flush failure-gating (review feedback)
PR #2545 self-review findings.

(1) originalModel was set from wsMetadataModel alone. On a hermes/pre-#240
workspace where MODEL_PROVIDER was never written but YAML has
runtime_config.model: "something", originalModel="" while the form
rendered "something" — handleSave's diff fired /model PUT on every
unrelated save (tier change → workspace auto-restart). Snapshot from
the actual rendered model in BOTH loadConfig branches so the diff
stays scoped to user-initiated changes.

(2) The store-flush test asserted the call happened but didn't pin
success-gating. A future refactor wrapping the PATCH in try/catch and
unconditionally calling updateNodeData would have shipped green and
left the badge lying about server-rejected writes. New test pins the
PATCH-rejects-no-flush invariant.

(3) Hermes-edge regression test for (1).

All 1214 canvas tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:37:52 -07:00
Hongming Wang 7f0c58d563 fix(canvas): ConfigTab is single source of truth for tier/provider/model
Three drift bugs in ConfigTab + ProviderModelSelector. Same root pattern:
the form's display, the diff baseline, and the canvas store all read or
write from different copies of the same data, so what the user sees and
what the runtime actually uses can diverge silently.

(1) currentModelId read runtime_config.model first; loadConfig overrode
only top-level config.model. With template YAML `runtime_config.model:
sonnet` and live MODEL_PROVIDER=`MiniMax-M2`, the form rendered
"Claude Code subscription / Claude Sonnet (OAuth)" while the container
env (and chat) used MiniMax-M2. Fix: loadConfig propagates
wsMetadataModel into BOTH places.

(2) handleSave's nextModel-vs-oldModel diff compared the form value to
the YAML default. After (1) mirrors wsMetadataModel into the form's
runtime_config.model for display, that diff was always non-zero on
no-op saves and would fire /model PUT — which auto-restarts. New
originalModel state tracks the loaded MODEL_PROVIDER and is the diff
baseline.

(3) handleSave PATCHed the workspace row but never pushed the same
fields into useCanvasStore.updateNodeData. User picked T3, hit Save &
Restart, DB updated to tier=3, header pill kept showing T2 until full
hydrate. Fix: mirror dbPatch into the store.

Bonus: ProviderModelSelector.handleProviderChange used to auto-default
the model to next.models[0] (alphabetically first) when switching
providers. User picked the MiniMax provider intending MiniMax-M2.7;
the form silently set MiniMax-M2 (first in the bucket) and the
workspace deployed with the wrong model. Now empty-default for
multi-model providers, force explicit pick — Save/Deploy already gate
on model.trim() === "".

Three new tests in ConfigTab.provider.test.tsx pin (1)/(2)/(3); two
existing ProviderModelSelector tests updated to reflect the no-silent-
default behaviour, with a new single-model-auto-pick test for the
0-vs-many boundary. 1212/1212 canvas tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:31:02 -07:00
Hongming Wang 5259ce3ea1 Merge pull request #2544 from Molecule-AI/fix/templates-yaml-log-and-coexistence-test
fix(workspace-server): log silent yaml.Unmarshal + coexistence test (#256, #257)
2026-05-03 06:04:51 +00:00
Hongming Wang 586d567a48 fix(workspace-server): log silent yaml.Unmarshal + coexistence test (#256, #257)
Two follow-ups from PR #2543's multi-model code review (audit #253).

1. **Log silent yaml.Unmarshal errors (#256).** When a malformed
   config.yaml made `yaml.Unmarshal(data, &raw)` fail, the affected
   template silently disappeared from /templates with no trace —
   operator could not distinguish "excluded due to parse error" from
   "never existed." That widened a real foot-gun once PR #2543 added
   structured top-level `providers:` (a string-shaped top-level
   `providers:` decoded into `[]providerRegistryEntry` would fail and
   drop the whole entry). Now logs `templates list: skip <id>:
   yaml.Unmarshal: <err>` and continues with the rest.

2. **Coexistence test (#257 part 1).** PR #2543 covered the structured
   registry and slug list in isolation. claude-code-default in
   production ships BOTH: top-level `providers:` (structured registry,
   2 entries) AND `runtime_config.providers:` (slug list, 3 entries).
   New `TestTemplatesList_BothProviderShapesCoexist` mirrors that
   layout, asserts both shapes surface independently with no
   cross-talk (e.g. a slug-only entry like `anthropic-api` does NOT
   synthesize a stub in the structured registry), and pins the JSON
   wire-shape for both fields side-by-side.

3. **`base_url: null` decoding assertion (#257 part 3).** Adds an
   explicit `got[0].BaseURL == ""` check in the existing
   `TestTemplatesList_SurfacesProviderRegistry` test, locking in the
   `string` (not `*string`) type. A future change to `*string` would
   surface as JSON `null` and break canvas's "no base_url = use
   provider defaults" branch — caught loudly by this assertion.

Tests: 11 TestTemplatesList_* now green, including the new
MalformedYAMLLogsAndSkips and BothProviderShapesCoexist.

The remaining piece of #257 — renaming `Providers []string` JSON tag
to `provider_slugs` — requires coordinated canvas updates across 4
files and is intentionally deferred to a separate PR (no canvas
churn while user is mid-test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:59 -07:00
Hongming Wang 0acd9b3454 Merge pull request #2543 from Molecule-AI/fix/templates-structured-provider-registry-235
fix(workspace-server): surface structured provider registry on /templates (#235)
2026-05-03 05:45:49 +00:00
Hongming Wang 992a0c6860 fix(workspace-server): surface structured provider registry on /templates (#235)
Closes the contract drift caught by audit #253. Task #235 ("Server:
enrich /templates payload with structured providers") was marked
completed, but `templates.go` only ever emitted the
`runtime_config.providers []string` slug list — the structured
ProviderEntry shape (auth_env, model_prefixes, model_aliases, base_url)
the description promised was never plumbed.

Templates ship the structured registry under a TOP-LEVEL `providers:`
block (claude-code carries 6+ entries today; hermes still uses the
slug list). Both shapes coexist and are independent — surface them as
two separate fields:

  - `providers`           → existing []string slug list (unchanged)
  - `provider_registry`   → new []providerRegistryEntry (structured)

The canvas's ProviderModelSelector comment block already anticipates
this ("Templates that ship explicit vendor metadata (future) should
override the heuristic."). With this field in place, the canvas can
optionally drop its prefix-inference fallback for templates that ship
an explicit registry — separate PR. Today's change is purely additive
on the server side; no canvas change required.

Tests:
- TestTemplatesList_SurfacesProviderRegistry: order preservation +
  field plumbing on a claude-code-shaped fixture (oauth + minimax)
  + JSON wire-shape gate to catch struct-tag renames.
- TestTemplatesList_OmitsProviderRegistryWhenAbsent: omitempty so
  legacy templates (hermes, langgraph) don't emit `null` and break
  Array.isArray on the canvas side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:42:42 -07:00
Hongming Wang 48bc518dfc Merge pull request #2541 from Molecule-AI/fix/workspace-server-universal-MODEL-env
fix(workspace-server): set universal MODEL env on every templated provision
2026-05-03 05:23:36 +00:00
Hongming Wang 2eea2b1315 Merge pull request #2540 from Molecule-AI/fix/wire-provider-model-selector
fix(canvas): wire ProviderModelSelector into MissingKeysModal + ConfigTab
2026-05-03 05:23:35 +00:00
Hongming Wang bd5f3428d5 Merge pull request #2539 from Molecule-AI/fix/preflight-explicit-empty-required-env
fix(runtime): explicit empty per-model required_env means "no auth"
2026-05-03 05:23:33 +00:00
Hongming Wang 8a86b66159 fix(workspace-server): set universal MODEL env on every templated provision
Bug B fix, server-side complement to molecule-runtime PR #2538.
The runtime PR taught `workspace/config.py` to honour
`MODEL_PROVIDER` over `runtime_config.model` from the template's
verbatim YAML. This PR is the upstream half: workspace-server's
`applyRuntimeModelEnv` now sets `MODEL=<picked>` for **every**
runtime, not just hermes (which got `HERMES_DEFAULT_MODEL` already).

Pre-fix: applyRuntimeModelEnv's per-runtime switch only emitted
HERMES_DEFAULT_MODEL for hermes; every other runtime got nothing,
so the adapter read its template's default model from
/configs/config.yaml. Surfaced 2026-05-02 — picking MiniMax-M2 in
canvas → workspace booted with model=sonnet (claude-code template
default) and demanded CLAUDE_CODE_OAUTH_TOKEN.

Post-fix: MODEL is set unconditionally before the per-runtime switch.
HERMES_DEFAULT_MODEL stays for backwards compat. Adapters opt in by
reading os.environ["MODEL"] in their executor (claude-code adapter
already does this since the same Bug B fix; see
workspace-configs-templates/claude-code-default/adapter.py).

Tests
=====
- `TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes`:
  table-driven across claude-code/hermes/langgraph/crewai + empty-model
  fallback + MODEL_PROVIDER-secret-fallback path. Adding a new
  runtime = adding a row, not writing a new test.
- All 6 sub-cases pass + existing
  `TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider`
  pin still green.

Why now
=======
This was authored alongside the runtime PR but stashed (not committed)
during a session-handoff cleanup. The molecule-runtime side shipped at
SHA 16ac895a and is live on PyPI as molecule-ai-workspace-runtime
0.1.84, but until the workspace-server side ships, the canvas-picked
MODEL env never reaches non-hermes adapters.

Caught by the systematic stash audit triggered by the user's
discovery that ProviderModelSelector had been similarly stashed.

Closes the workspace-server side of #246. Builds on merged #2538.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:10:51 -07:00
Hongming Wang 5cc02aa11c fix(canvas): wire ProviderModelSelector into MissingKeysModal + ConfigTab
The shared <ProviderModelSelector> component was authored on disk but
never landed — three deploy/configure surfaces still rendered the
legacy free-text "MODEL slug" input + provider-radio list. Tasks #239
and #243 closed at "component exists" rather than "user-visible
behavior changed", and the integration sat in a working-tree stash
that was never committed.

This PR is the missing integration:
- canvas/src/components/ProviderModelSelector.tsx (new, 509 lines):
  single-source-of-truth Provider→Model cascade. Builds a catalog
  from `template.models[].required_env` (groups by sorted+joined env
  names so two MiniMax models with the same auth land in one
  provider), exposes vendor detection helper + back-derivation. No
  per-template hardcoding — fully driven by the upstream payload.
- canvas/src/components/MissingKeysModal.tsx: replaces the inline
  `<input type="text">` + `<fieldset>` of provider radios with one
  `<ProviderModelSelector>`. Same external contract
  (`onKeysAdded(model)`), so callers in useTemplateDeploy don't move.
- canvas/src/components/tabs/ConfigTab.tsx: replaces ad-hoc Model
  text input + Provider radio with the same selector, fixing the
  display-vs-storage drift class that #190 first patched.

Tests
=====
- ProviderModelSelector.test.tsx (new, 269 lines): cascade behavior,
  vendor auto-snap, back-derivation from saved config.
- MissingKeysModal.cascade.test.tsx: rewritten to assert dropdown
  shape (was asserting the legacy text-input shape).
- ConfigTab.hermes.test.tsx + ConfigTab.provider.test.tsx: updated
  for the new selector shape.
- 1208/1208 canvas tests pass locally.

User-visible fix: clicking any deploy/configure surface from the
sidebar now shows the cascade UX (Provider dropdown first, Model
dropdown filtered) instead of the legacy free-text MODEL slug.

Closes the integration gap behind #239 + #243. Builds on merged
runtime PRs #2538 (universal MODEL_PROVIDER) + #32 + #38 (per-vendor
audit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:04:40 -07:00
Hongming Wang fd4b4e0723 test: pin null-required_env tolerance + drop unused MINIMAX env clear
Two self-review nits on the prior commit:
- Add test_per_model_required_env_null_treated_as_empty_no_auth — pins
  parser tolerance for YAML 'required_env:' (deserializes to None). The
  'or []' fallback handles it, but the behavior wasn't asserted, and a
  template author who writes 'required_env:' with no value (common YAML
  mistake) needs the no-auth path, not a confusing TypeError.
- Drop the MINIMAX_API_KEY delenv from the explicit-empty test — there's
  no MINIMAX in any required_env list of that scenario, so the cleanup
  was dead noise.

78/78 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:56:40 -07:00
Hongming Wang 3e5955f04f fix(runtime): explicit empty per-model required_env means "no auth"
Two follow-ups from the independent review of #2538.

preflight.py
============
Today: `if per_model_env: required_env = list(per_model_env)` falls
through on `[]`, so a template entry that says "this model needs no
auth" (`required_env: []` — Ollama, llamafile, self-hosted OpenAI-
compat, anything where the SDK doesn't surface a key) is silently
overridden by the top-level fallback list. The template author cannot
express a zero-auth model without lying about its env requirements.

Fix: key off `"required_env" in entry` (key presence, not truthiness).
Missing key still falls back to top-level — that path is unchanged
and preserves "many templates list name/description per model without
enumerating env vars when auth is identical across the family". Empty
list now wins outright. Comment updated to call out the distinction.

test_preflight.py
=================
Renamed `test_per_model_match_with_no_required_env_falls_back_to_top_level`
to `…_no_required_env_KEY_…` and tightened its docstring to reflect
that it's the missing-KEY case only. Added new
`test_per_model_explicit_empty_required_env_means_no_auth` to pin the
new explicit-empty semantic.

test_config.py
==============
New `test_runtime_config_model_env_wins_over_explicit_yaml`. Pins the
intentional precedence inversion shipped in #2538 with both
MODEL_PROVIDER and runtime_config.model in YAML set — MODEL_PROVIDER
wins. Without this pin a future refactor could quietly restore the
old YAML-wins order and re-introduce Bug B.

77/77 targeted tests pass locally.

Closes #250 (review follow-up). Builds on merged #2538.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:51:01 -07:00
Hongming Wang 16ac895a39 Merge pull request #2538 from Molecule-AI/fix/canvas-picked-model-universal
fix(runtime): canvas-picked model wins universally + per-model required_env
2026-05-03 04:44:06 +00:00
Hongming Wang 97ebd1910a fix(runtime): canvas-picked model wins universally + per-model required_env
Two surgical edits to the molecule-runtime workspace package that fix
Bug B (canvas-picked model silently dropped for templated workspaces)
and Bug D (preflight rejects valid auth for non-default models),
universally for every adapter.

Bug B — canvas-picked model dropped (config.py)
================================================
Before: load_config resolved runtime_config.model as
  runtime_raw.get("model") or model
which means a template's `runtime_config.model: sonnet` always wins
over the canvas-picked MODEL_PROVIDER env var. Surfaced 2026-05-02
during MiniMax E2E — picking MiniMax-M2.7 in canvas, server plumbed
MODEL_PROVIDER=MiniMax-M2.7 correctly, but the workspace booted with
sonnet because the template's verbatim config.yaml won.

After:
  os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model

Centralising in load_config means EVERY adapter (claude-code, hermes,
codex, langgraph, future ones) gets canvas-picked-model passthrough
for free — no per-adapter env-reading code required.

Bug D — preflight per-model required_env (preflight.py)
========================================================
Before: preflight read the top-level required_env list, which
declares the auth needed by the *default* model. A template like
claude-code-default declares CLAUDE_CODE_OAUTH_TOKEN at the top
level. When a user picked MiniMax instead and only set
MINIMAX_API_KEY, preflight rejected the workspace with
"missing CLAUDE_CODE_OAUTH_TOKEN" and the workspace crash-looped
despite the user having satisfied the picked model's actual auth.

After: when runtime_config.models[] declares per-entry required_env,
preflight matches the picked model id (case-insensitive) and uses
that entry's required_env outright instead of the top-level list.
REPLACE semantics, not union — different models have *different*
auth paths (OAuth vs API key vs third-party provider key); unioning
would re-introduce the very crash-loop this fix closes.

Surface enabling both fixes (config.py)
========================================
RuntimeConfig now carries `models: list[dict]` so the canvas Model
dropdown source flows through to preflight without forcing the
parser schema to grow. Malformed entries are silently dropped to
match the rest of the lenient parser.

Tests
=====
- workspace/tests/test_preflight.py: 9 new tests covering the
  per-model lookup (case-insensitive, REPLACE not union, fallback
  to top-level when no models[] or no match, multi-entry, malformed
  entries dropped, etc.)
- workspace/tests/test_config.py: existing 48 pass; field
  initialisation already covered by parser tests.
- All 75 targeted tests pass locally; CI runs the full suite
  including coverage gate.

Closes part of #246. Sibling PR opens against
molecule-ai-workspace-template-claude-code for per-template
defensive fixes + boot debug logging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:36:24 -07:00
Hongming Wang d95877c88d Merge pull request #2535 from Molecule-AI/fix/hermes-first-deploy-model-provider-persistence
Persist canvas-selected model+provider on first deploy
2026-05-03 02:25:03 +00:00
Hongming Wang 1b75fddb8e Merge pull request #2536 from Molecule-AI/chore/prune-manifest-to-4-runtimes
chore(manifest): prune to 4 actively-supported runtimes
2026-05-03 02:24:50 +00:00
Hongming Wang f33e59ba8c chore(manifest): prune to 4 actively-supported runtimes
Deletes the 5 unsupported workspace_templates from manifest.json
(langgraph, crewai, autogen, deepagents, gemini-cli). The runtime
matrix is now claude-code / hermes / openclaw / codex — the four
templates with shipping images, working A2A integration, and active
CI publish-image cascades.

Mirrors the prune in:
  - workspace-server/internal/handlers/runtime_registry.go
    (fallbackRuntimes for dev/test contexts that boot without the
    manifest mounted)
  - workspace-server/internal/handlers/workspace_provision.go
    (sanitizeRuntime: empty/unknown → "claude-code", was "langgraph";
    removes the langgraph/deepagents-specific runtime_config skip
    branch — they're no longer supported, so the block is dead)
  - tests for both: rename TestEnsureDefaultConfig_LangGraph →
    _Hermes, TestEnsureDefaultConfig_EmptyRuntimeDefaultsToLangGraph
    → _ClaudeCode, drop TestEnsureDefaultConfig_DeepAgents,
    update TestSanitizeRuntime_Allowlist + the two
    TestResolveRestartTemplate_* cases that pinned langgraph-default
    as the safe-default name

Why this is safe: production reads manifest.json at boot and uses it
as the authoritative allowlist; the 5 removed runtimes have not
shipped working images for ≥1 release cycle. Any provision request
naming one will now coerce to claude-code (with a log line) instead
of returning a runtime that has no functioning template repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:21:47 -07:00
Hongming Wang a1de71dd53 fix(workspace-server): persist canvas-selected model + provider on first deploy
When the canvas POSTs /workspaces with {model: "minimax/MiniMax-M2.7"},
the model slug was never written to workspace_secrets. The workspace
booted hermes once with HERMES_DEFAULT_MODEL set from payload.Model, but
on every subsequent restart applyRuntimeModelEnv's fallback chain found
nothing in envVars["MODEL_PROVIDER"] (because nothing wrote it) and
hermes silently fell through to the template default
(nousresearch/hermes-4-70b) — wrong provider keys → hermes gateway
401'd → /health poll failed → molecule-runtime never registered →
"container started but never called /registry/register".

Worse, LLM_PROVIDER was never written either (the canvas doesn't send
provider), so CP user-data wrote no provider: field to
/configs/config.yaml and derive-provider.sh fell through to PROVIDER=auto
on every custom-prefix slug.

Fix: after the workspace row commits, persist MODEL_PROVIDER (verbatim
slug) and LLM_PROVIDER (derived from slug prefix) to workspace_secrets.
LLM_PROVIDER is gating-only — derive-provider.sh remains the runtime
source of truth and can override at boot. Reuses extracted
setModelSecret / setProviderSecret helpers (refactored out of SetModel /
SetProvider gin handlers) so SQL stays in one place.

Symptom: failed-workspace 95ed3ff2 (2026-05-02).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:21:01 -07:00
Hongming Wang ec63597545 Merge pull request #2527 from Molecule-AI/dependabot/npm_and_yarn/canvas/postcss-8.5.13
chore(deps)(deps-dev): bump postcss from 8.5.12 to 8.5.13 in /canvas
2026-05-03 02:08:31 +00:00
Hongming Wang 114c941d27 Merge pull request #2534 from Molecule-AI/fix/cascade-deploy-modal
fix(deploy-modal): snap provider radio when model resolves to a provider
2026-05-03 02:04:04 +00:00
Hongming Wang 9eb22333a5 fix(deploy-modal): snap provider radio when model resolves to a provider
The TemplatePalette deploy modal (MissingKeysModal → ProviderPickerModal)
let the model field and provider radio drift apart. When a hermes
template defaulted the model to "MiniMax-M2.7-highspeed" but the radio
defaulted to providers[0] (Anthropic), the env-var input below asked
for ANTHROPIC_API_KEY. A user pasting their MINIMAX_API_KEY there (or
just dismissing the dialog) ended up with a workspace whose
runtime_config.model=MiniMax + ANTHROPIC_API_KEY env — the hermes
adapter then crashed during boot before /registry/register, surfacing
as WORKSPACE_PROVISION_FAILED 12 minutes later.

Caught 2026-05-02 on hongming/Hermes Agent (workspace 95ed3ff2-…
ended with: "container started but never called /registry/register").

Sibling of the ConfigTab cascade fix in PR #2516 (task #236) — same
pattern, different surface. Plumbs the template's full ModelSpec[]
(with required_env per model) into the picker. When the typed model
matches a registry entry, snap the radio so the env-var fields
underneath match what the model actually needs.

Free-text models (typed slug not in the registry) and models with no
required_env (local/self-hosted endpoints) leave the radio alone — the
user can still pick a provider manually. Backwards-compat: callers
that don't pass `models` get the pre-cascade behavior, pinned by a
regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:01:13 -07:00
dependabot[bot] 993cc4d467 chore(deps)(deps-dev): bump postcss from 8.5.12 to 8.5.13 in /canvas
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.12 to 8.5.13.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.12...8.5.13)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 01:37:17 +00:00
Hongming Wang fd5fe34f69 Merge pull request #2523 from Molecule-AI/dependabot/github_actions/actions/github-script-9.0.0
chore(deps)(deps): bump actions/github-script from 7.1.0 to 9.0.0
2026-05-03 01:37:00 +00:00
Hongming Wang 0d8b0c37a6 Merge pull request #2521 from Molecule-AI/dependabot/github_actions/actions/checkout-6
chore(deps)(deps): bump actions/checkout from 4 to 6
2026-05-03 01:36:57 +00:00
dependabot[bot] 572050f1ed chore(deps)(deps): update starlette requirement in /workspace
Updates the requirements on [starlette](https://github.com/Kludex/starlette) to permit the latest version.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/0.38.0...1.0.0)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 01:36:45 +00:00
Hongming Wang 252e126207 Merge pull request #2522 from Molecule-AI/dependabot/github_actions/docker/setup-buildx-action-4.0.0
chore(deps)(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0
2026-05-03 01:27:03 +00:00
Hongming Wang e84df73e96 Merge pull request #2528 from Molecule-AI/dependabot/github_actions/docker/build-push-action-7.1.0
chore(deps)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
2026-05-03 01:27:00 +00:00
Hongming Wang 7db4129877 Merge pull request #2525 from Molecule-AI/dependabot/github_actions/imjasonh/setup-crane-0.5
chore(deps)(deps): bump imjasonh/setup-crane from 0.4 to 0.5
2026-05-03 01:25:37 +00:00
Hongming Wang 9c03b1084f Merge pull request #2524 from Molecule-AI/dependabot/pip/workspace/opentelemetry-api-gte-1.41.1
chore(deps)(deps): update opentelemetry-api requirement from >=1.24.0 to >=1.41.1 in /workspace
2026-05-03 01:25:34 +00:00
Hongming Wang 476dbc83a3 Merge pull request #2530 from Molecule-AI/dependabot/pip/workspace/opentelemetry-exporter-otlp-proto-http-gte-1.41.1
chore(deps)(deps): update opentelemetry-exporter-otlp-proto-http requirement from >=1.24.0 to >=1.41.1 in /workspace
2026-05-03 01:25:31 +00:00
Hongming Wang cf118df2af Merge pull request #2520 from Molecule-AI/dependabot/go_modules/workspace-server/github.com/creack/pty-1.1.24
chore(deps)(deps): bump github.com/creack/pty from 1.1.18 to 1.1.24 in /workspace-server
2026-05-03 01:25:28 +00:00
Hongming Wang 8dc07b46dd Merge pull request #2526 from Molecule-AI/dependabot/pip/workspace/python-multipart-gte-0.0.27
chore(deps)(deps): update python-multipart requirement from >=0.0.18 to >=0.0.27 in /workspace
2026-05-03 01:25:25 +00:00
Hongming Wang 495cc38dd9 Merge pull request #2531 from Molecule-AI/dependabot/pip/workspace/pyyaml-gte-6.0.3
chore(deps)(deps): update pyyaml requirement from >=6.0 to >=6.0.3 in /workspace
2026-05-03 01:25:20 +00:00
Hongming Wang ecfb160b0d Merge pull request #2532 from Molecule-AI/dependabot/npm_and_yarn/canvas/jsdom-29.1.1
chore(deps)(deps-dev): bump jsdom from 29.1.0 to 29.1.1 in /canvas
2026-05-03 01:25:17 +00:00
dependabot[bot] dfc1f6d455 chore(deps)(deps): update pyyaml requirement in /workspace
Updates the requirements on [pyyaml](https://github.com/yaml/pyyaml) to permit the latest version.
- [Release notes](https://github.com/yaml/pyyaml/releases)
- [Changelog](https://github.com/yaml/pyyaml/blob/6.0.3/CHANGES)
- [Commits](https://github.com/yaml/pyyaml/compare/6.0...6.0.3)

---
updated-dependencies:
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:25 +00:00
dependabot[bot] f61750808e chore(deps)(deps-dev): bump jsdom from 29.1.0 to 29.1.1 in /canvas
Bumps [jsdom](https://github.com/jsdom/jsdom) from 29.1.0 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.0...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:25 +00:00
dependabot[bot] 0e0550c640 chore(deps)(deps): update opentelemetry-exporter-otlp-proto-http requirement
Updates the requirements on [opentelemetry-exporter-otlp-proto-http](https://github.com/open-telemetry/opentelemetry-python) to permit the latest version.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/v1.41.1/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.24.0...v1.41.1)

---
updated-dependencies:
- dependency-name: opentelemetry-exporter-otlp-proto-http
  dependency-version: 1.41.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:21 +00:00
dependabot[bot] c46db97ac6 chore(deps)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...bcafcacb16a39f128d818304e6c9c0c18556b85f)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:17 +00:00
dependabot[bot] 1d99b3b8ae chore(deps)(deps): update python-multipart requirement in /workspace
Updates the requirements on [python-multipart](https://github.com/Kludex/python-multipart) to permit the latest version.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.18...0.0.27)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.27
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:15 +00:00
dependabot[bot] 6c6c6eb1e8 chore(deps)(deps): bump imjasonh/setup-crane from 0.4 to 0.5
Bumps [imjasonh/setup-crane](https://github.com/imjasonh/setup-crane) from 0.4 to 0.5.
- [Release notes](https://github.com/imjasonh/setup-crane/releases)
- [Commits](https://github.com/imjasonh/setup-crane/compare/31b88efe9de28ae0ffa220711af4b60be9435f6e...6da1ae018866400525525ce74ff892880c099987)

---
updated-dependencies:
- dependency-name: imjasonh/setup-crane
  dependency-version: '0.5'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:13 +00:00
dependabot[bot] 8072f00b2f chore(deps)(deps): update opentelemetry-api requirement in /workspace
Updates the requirements on [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) to permit the latest version.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/v1.41.1/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.24.0...v1.41.1)

---
updated-dependencies:
- dependency-name: opentelemetry-api
  dependency-version: 1.41.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:11 +00:00
dependabot[bot] e1f7d49575 chore(deps)(deps): bump actions/github-script from 7.1.0 to 9.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.1.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:09 +00:00
dependabot[bot] ab7ac2e103 chore(deps)(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:05 +00:00
dependabot[bot] 3598eb41d1 chore(deps)(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:01 +00:00
dependabot[bot] 82d0655fe9 chore(deps)(deps): bump github.com/creack/pty in /workspace-server
Bumps [github.com/creack/pty](https://github.com/creack/pty) from 1.1.18 to 1.1.24.
- [Release notes](https://github.com/creack/pty/releases)
- [Commits](https://github.com/creack/pty/compare/v1.1.18...v1.1.24)

---
updated-dependencies:
- dependency-name: github.com/creack/pty
  dependency-version: 1.1.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:22:48 +00:00
Hongming Wang ea967d5787 Merge pull request #2518 from Molecule-AI/docs/hermes-plugin-status-update
docs(integrations): hermes plugin path status post-PR #32 merge
2026-05-02 16:01:47 +00:00
Hongming Wang 2dd5684e73 docs(integrations): update hermes plugin path status to post-merge
PR #32 (workspace template) merged 2026-05-02; image rebuild
succeeded. Plugin baked in. Local full-chain E2E green; caught + fixed
a real KeyError in upstream hermes_cli/tools_config.py. Upstream PR
#18775 still OPEN/CONFLICTING — not on critical path.

Also rewrites hermes-platform-plugins-upstream-pr.md to reflect the
final landing shape (existing hermes_cli/plugins.py, not a new
plugins/platforms/ system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:42:00 -07:00
Hongming Wang 2552779d97 Merge pull request #2517 from Molecule-AI/test/all-runtimes-a2a-e2e-harness
test(e2e): unified A2A round-trip parity harness across all 4 runtimes
2026-05-02 11:40:14 +00:00
Hongming Wang d88c160e56 test(e2e): wire SaaS auth headers (TENANT_ADMIN_TOKEN + TENANT_ORG_ID)
The harness needs Authorization + X-Molecule-Org-Id (per-tenant, NOT
CP_ADMIN_API_TOKEN) when targeting *.moleculesai.app subdomains.
Existing single-Origin-header form silent-failed with 404 against
staging tenants since the SaaS edge WAF rewrites unauthenticated
/workspaces calls to Next.js (per
reference_saas_waf_origin_header.md).

Switch to a headers array so multiple -H flags compose cleanly with
curl arg-quoting, and document the env var contract at the top of
the script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:36:23 -07:00
Hongming Wang 5aaac7d2d9 test(e2e): unified A2A round-trip parity harness across all 4 runtimes
Adds two scripts:

  scripts/test-all-runtimes-a2a-e2e.sh
    Provisions one workspace per runtime (claude-code, hermes, codex,
    openclaw), sets provider keys, waits online, sends two A2A messages
    per workspace. First message validates round-trip; second message
    validates session continuity. Cleans up via trap on EXIT.

  scripts/test-hermes-plugin-e2e.sh
    Hermes-only variant focused on the plugin /a2a/inbound path.
    Proof-point: session continuity between turns (the plugin path's
    deliverable; old chat-completions path lost context per turn).

Both honor SKIP_<runtime> env vars for incremental testing and tolerate
the SaaS edge WAF Origin header requirement (per
reference_saas_waf_origin_header.md).

Run:
  PLATFORM=https://demo-tenant.staging.moleculesai.app \\
      ./scripts/test-all-runtimes-a2a-e2e.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:36:23 -07:00
Hongming Wang 15dd1f26c3 Merge pull request #2513 from Molecule-AI/auto-sync/main-35cb6ba0
chore: sync main → staging (auto, merge 35cb6ba0)
2026-05-02 10:53:27 +00:00
Hongming Wang 8083fd8b7d Merge branch 'staging' into auto-sync/main-35cb6ba0 2026-05-02 03:39:00 -07:00
Hongming Wang 1f77f41a80 Merge pull request #2514 from Molecule-AI/fix/honest-v1-tolerance-comments
docs(a2a): correct misleading v1-tolerance comments
2026-05-02 09:46:33 +00:00
Hongming Wang 119518a612 Merge pull request #2515 from Molecule-AI/fix/sweep-cf-tunnels-parallelize-deletes
fix(sweep-cf-tunnels): parallelize deletes + raise workflow timeout
2026-05-02 09:38:31 +00:00
Hongming Wang 8bf29b7d0e fix(sweep-cf-tunnels): parallelize deletes + raise workflow timeout
The hourly Sweep stale Cloudflare Tunnels job got cancelled mid-cleanup
on 2026-05-02 (run 25248788312, killed at 5min after deleting 424/672
stale tunnels). A second manual dispatch finished the remaining 254
fine, so the immediate backlog cleared, but two underlying bugs would
re-trip on the next big cleanup.

Bug 1: serial delete loop. The execute branch was a `while read; do
curl -X DELETE; done` pipeline at ~0.7s/tunnel — fine for the
steady-state cleanup of a handful, but a 600+ backlog needs ~7-8min.
This commit fans out to $SWEEP_CONCURRENCY (default 8) workers via
`xargs -P 8 -L 1 -I {} bash -c '...' _ {} < "$DELETE_PLAN"`. With 8x
parallelism the same 600+ list drains in ~60s. Notes:

  - We use stdin (`<`) not GNU's `xargs -a FILE` so the script stays
    portable to BSD xargs (matters for local-runner testing on macOS).
  - We pass ONLY the tunnel id on argv. xargs tokenizes on whitespace
    by default; tab-separating id+name on argv risks mangling. The
    name is kept in a side-channel id->name map ($NAME_MAP) and looked
    up by the worker only on failure, for FAIL_LOG readability.
  - Workers print exactly `OK` or `FAIL` on stdout; tally with
    `grep -c '^OK$' / '^FAIL$'`.
  - On non-zero FAILED, log the first 20 lines of $FAIL_LOG as
    "Failure detail (first 20):" — same diagnostic surface as before
    but consolidated so we don't spam logs on a flaky CF API.

Bug 2: workflow's 5-min cap was set as a hangs-detector but turned out
to be a real-job-too-slow detector. Raised to 30 min — generous
headroom for the ~60s steady-state run while still surfacing genuine
hangs (and in line with the sweep-cf-orphans companion job).

Bug 3 (drive-by): the existing trap was `trap 'rm -rf "$PAGES_DIR"'
EXIT`, which would have been silently overwritten by any later trap
registration. Replaced with a single `cleanup()` function that wipes
PAGES_DIR + all four new tempfiles (DELETE_PLAN, NAME_MAP, FAIL_LOG,
RESULT_LOG), called once via `trap cleanup EXIT`.

Verification:
  - bash -n scripts/ops/sweep-cf-tunnels.sh: clean
  - shellcheck -S warning scripts/ops/sweep-cf-tunnels.sh: clean
  - python3 yaml.safe_load on the workflow: clean
  - Synthetic 30-line delete plan with every 7th id sentinel'd to
    return {"success":false}: TEST PASS, DELETED=26 FAILED=4, FAIL_LOG
    side-channel name lookup verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:35:46 -07:00
Hongming Wang fc33cf1131 docs(a2a): correct misleading v1-tolerance comments
Follow-up to PR #2509/#2510. The defensive v1-detection branches in
extract_attached_files (Python) and extractFilesFromTask (TypeScript)
were merged with comments claiming they fix a "v0→v1 silent-drop"
bug that surfaced as the 2026-05-01 hongming "no text content"
incident. Live test disproved that hypothesis: a2a-sdk's JSON-RPC
layer validates inbound requests against the v0 Pydantic union, so
v1 shapes are rejected at the request boundary — the v1 detection
branch is unreachable on the JSON-RPC ingress path. The actual root
cause of the hongming incident was the missing /workspace chown
fixed by CP PR #381 + test #382.

Update the comments to honestly describe these branches as
defensive future-proofing (kept against an eventual SDK schema
migration or in-process callers that construct Parts directly from
protobuf), not as fixes for an observed bug. Also trims
ChatTab.tsx's outbound-shape comment block from ~21 lines to a
3-line pointer to the SDK union.

Comment-only change. No behavior change. 86 workspace tests + 91
canvas tests still pass.
2026-05-02 02:33:00 -07:00
github-actions[bot] 03c1cbf12b chore: sync main → staging (auto) 2026-05-02 09:27:17 +00:00
Hongming Wang 35cb6ba089 Merge pull request #2512 from Molecule-AI/feat/register-codex-runtime
feat: register codex runtime + runtime native-MCP design docs
2026-05-02 02:26:56 -07:00
Hongming Wang ce0188d5b4 Merge pull request #2499 from Molecule-AI/auto-sync/main-e7375348
chore: sync main → staging (auto, ff to e7375348)
2026-05-02 09:22:51 +00:00
hongming 7224276de0 feat: register codex runtime + runtime native-MCP design docs
Adds the OpenAI Codex CLI as a Molecule workspace runtime and lands
the design docs that drove the runtime native-MCP push parity work
across claude-code, hermes, openclaw, and codex.

manifest.json:
- Adds `codex` workspace_template entry pointing at the new
  Molecule-AI/molecule-ai-workspace-template-codex repo (initial
  commit landed there in parallel; 14 files / 1411 LOC). The
  workspace-server runtime registry already had `codex` in its
  fallback set — this entry makes it manifest-reachable in prod.

docs/integrations/:
- runtime-native-mcp-status.md — index across all four runtime streams
- codex-app-server-adapter-design.md — full design including v2 RPC
  sequence, executor skeleton, schema-vs-runtime drift findings
  (real codex 0.72 returns thread.id, schema says thread.threadId)
- hermes-platform-plugins-upstream-pr.md — pre-submission draft of
  the hermes-agent upstream PR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:21:11 -07:00
Hongming Wang 3d7b4b70ff Merge pull request #2511 from Molecule-AI/fix/redeploy-tolerate-e2e-teardown-race
fix(redeploy-staging): tolerate e2e-* teardown race in fleet HTTP 500
2026-05-02 09:19:45 +00:00
Hongming Wang 6e0eb2ddc9 fix(redeploy-staging): tolerate e2e-* teardown race in fleet HTTP 500
Recurring failure pattern in redeploy-tenants-on-staging:

  ##[error]redeploy-fleet returned HTTP 500
  ##[error]Process completed with exit code 1.

with the per-tenant breakdown in the response body showing the failures
were on ephemeral e2e-* tenants (saas/canvas/ext) whose parent E2E run
torn them down mid-redeploy — SSM exit=2 because the EC2 was already
terminating, or healthz timeout because the CF tunnel was already gone.
The actual operator-facing tenants (dryrun-98407, demo-prep, etc) all
rolled fine in the same call.

This shape repeats every staging push that overlaps an active E2E run.
The downstream `Verify each staging tenant /buildinfo matches published
SHA` step ALREADY distinguishes STALE vs UNREACHABLE for exactly this
reason (per #2402); only the top-level `if HTTP_CODE != 200; exit 1`
gate misclassifies the race.

Filter: HTTP 500 + every failed slug matches `^e2e-` → soft-warn and
fall through to verify. Any non-e2e-* failure or non-500 HTTP remains
a hard fail, with the failed non-e2e slugs surfaced in the error so
the operator doesn't have to dig the response body out of CI.

Verified the gate logic with 6 synthetic CP responses (happy / e2e-only
race / mixed real+e2e fail / non-200 / 200+ok=false / all-real-fail) —
all behave correctly.

prod's redeploy-tenants-on-main is intentionally NOT touched: prod CP
serves no e2e-* tenants, so the race can't occur there and the strict
gate is the right behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:17:36 -07:00
Hongming Wang 1ce9b7f716 Merge pull request #2510 from Molecule-AI/fix/revert-canvas-v1-outbound
fix(canvas): revert v1 outbound file part shape — JSON-RPC layer rejects it
2026-05-02 08:43:53 +00:00
Hongming Wang 3ce7c11a13 fix(canvas): revert v1 outbound file part shape
The previous PR (#2509) flipped canvas outbound file parts to the v1
flat shape `{url, filename, mediaType}` based on a hypothesis that
a2a-sdk's JSON-RPC parser silently dropped v0 `{kind:"file", file:{...}}`
shapes. Live test shows the opposite: a2a-sdk's JSON-RPC layer
validates against the v0 Pydantic discriminated union (TextPart |
FilePart | DataPart), so v1 flat shape is rejected with:

    Invalid Request:
      params.message.parts.0.TextPart.text — Field required
      params.message.parts.0.FilePart.file — Field required
      params.message.parts.0.DataPart.data — Field required

The actual root cause of the user-visible "Error: message contained
no text content" was the missing `/workspace` chown (CP PR #381 +
test pin #382), not a wire-shape mismatch. Verified end-to-end by
sending a v0 image-only message after PR #381 + workspace re-provision
— agent receives the file, reads its bytes, and replies normally.

Reverting only the canvas outbound shape. Defensive v1-tolerance
stays in:
  - workspace/executor_helpers.py — extract_attached_files still
    accepts v1 protobuf parts in case a future client emits them or
    a future SDK release flips internal representation. Harmless on
    the v0 hot path.
  - canvas/message-parser.ts — extractFilesFromTask still tolerates
    v1 shape on incoming agent responses. Some agents may emit v1
    when their internal serializer round-trips through protobuf.

Tests stay green (91 canvas, 86 workspace).
2026-05-02 01:31:56 -07:00
Hongming Wang bf83af0960 Merge pull request #2509 from Molecule-AI/fix/a2a-v1-file-part-shape
fix(a2a): send v1 file Part shape; tolerate v1 server-side
2026-05-02 08:13:52 +00:00
Hongming Wang 02a8841402 fix(a2a): send v1 file Part shape; tolerate v1 server-side
Image-only chats surface "Error: message contained no text content"
because canvas posts v0 `{kind:"file", file:{uri,name,mimeType}}` shapes
that the workspace runtime's a2a-sdk v1 protobuf parser silently drops:
v1 `Part` has fields `[text, raw, url, data, metadata, filename,
media_type]` and `ignore_unknown_fields=True` discards `kind`+`file`,
producing a fully-empty Part. With no text and no extracted file
attachments, the executor's "no text content" guard fires.

Three coordinated changes close the gap:

1. canvas/ChatTab.tsx — outbound file parts now carry the v1 flat
   shape `{url, filename, mediaType}` so the v1 protobuf parser
   populates Part fields instead of dropping them.
2. workspace/executor_helpers.py — extract_attached_files learns the
   v1 detection branch (non-empty `part.url` + `filename` +
   `media_type`) alongside the existing v0 RootModel and flat-file
   shapes. Defends every runtime that mounts the OSS wheel against
   the same drop, including any pre-fix client still on the wire.
3. canvas/message-parser.ts — extractFilesFromTask tolerates the v1
   shape on incoming agent responses too, so file chips render in
   chat history regardless of which Part shape the runtime emits.

Test pins:
- workspace/tests/test_executor_helpers.py:
  + v1 protobuf shape extraction
  + empty-Part defense (v0→v1 silent-drop fall-through returns [])
- canvas message-parser test:
  + v1 protobuf flat parts
  + filename fallback to URL basename for v1
2026-05-02 00:58:05 -07:00
Hongming Wang b36eed97f6 Merge pull request #2508 from Molecule-AI/fix/sweep-cf-tunnels-arg-too-long
fix(sweep-cf-tunnels): buffer pages to disk to avoid argv ARG_MAX
2026-05-02 07:45:01 +00:00
Hongming Wang a117a60eed fix(sweep-cf-tunnels): buffer pages to disk to avoid argv ARG_MAX
The page-merge loop passed the entire accumulating tunnel JSON to
python3 -c via argv on every iteration. On a busy account (verified
2026-05-02: 672 tunnels, 14 pages on Hongmingwangrabbit account) this
exceeds the GH Ubuntu runner's combined argv+envp limit (~128 KB) and
dies with `python3: Argument list too long` at exit 126 — the workflow
has been silently failing this way since the very first run that hit a
real account, masked earlier by a missing-CF_ACCOUNT_ID secret check.

Buffer each page response to a file under a temp dir, merge from disk
at the end. Also bumps the page cap from 20 to 40 (1000 → 2000 tunnel
ceiling) so the existing soft-cap warning has headroom; the disk-merge
shape is O(n) in tunnel count rather than the previous O(n^2) so the
larger ceiling is cheap.

Verified locally against the live account (672 tunnels): script now
runs cleanly to the existing MAX_DELETE_PCT safety gate, which trips
at 99% > 90% as designed and surfaces the actual orphan backlog for
operator-driven cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:42:25 -07:00
Hongming Wang cdbf54beed Merge pull request #2507 from Molecule-AI/fix/canary-prompt-explicit-echo
fix(canary): reframe smoke prompt to give GPT-4o explicit permission to echo
2026-05-02 06:55:39 +00:00
Hongming Wang fa9e29f2f5 fix(canary): reframe smoke prompt to give GPT-4o explicit permission to echo
Canary started flaking 2026-05-01 22:11 with model-refusal replies:
  - "I'm unable to do that."
  - "I'm unable to fulfill that request. Can I assist you with anything else?"
  - "I'm unable to reply with responses that don't allow me to fulfill tasks…"
3 fails / 10 recent runs ≈ 30% flake.

Trigger: 2026-04-30's Platform Capabilities preamble (#2332) added the
directive "Use them proactively" to the top of every system prompt.
Combined with the heavy A2A + HMA tool docs further down, the model
reads the contrived bare-echo prompt ("Reply with exactly: PONG") as
out-of-role and intermittently refuses.

Real user prompts don't hit this — only the synthetic smoke prompt does,
so the right fix is in the canary's prompt phrasing, not the platform's
system prompt (which is correctly priming agents toward tool use). New
phrasing explicitly tells the model "this is a smoke test" and "no
tools or memory are needed" so it has permission to comply.

Also updates the child workspace's CHILD_PONG prompt with the same
framing — same failure mode would have hit it once full-mode runs again.

No code change to system prompt, no test infra change. Just two prompt
strings + a load-bearing comment so future readers don't trim back to
the brittle phrasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:53:24 -07:00
Hongming Wang 12807962d2 Merge pull request #2506 from Molecule-AI/ci/secret-scan-required-and-precommit
secret-scan: align local pre-commit + extend drift lint (closes #1569 root)
2026-05-02 06:52:28 +00:00
Hongming Wang 0d25922f91 Merge branch 'staging' into ci/secret-scan-required-and-precommit 2026-05-01 23:48:50 -07:00
Hongming Wang 43c234df35 secret-scan: align local pre-commit + extend drift lint (closes #1569 root)
#1569 Phase 1 discovery (2026-05-02) found six historical credential
exposures in molecule-core git history. All confirmed dead — but the
reason they got committed in the first place was that the local
pre-commit hook had two gaps that the canonical CI gate (and the
runtime's hook) didn't:

  1. **Pattern set was incomplete.** Local hook checked
     `sk-ant-|sk-proj-|ghp_|gho_|AKIA|mol_pk_|cfut_` — missing
     `ghs_*`, `ghu_*`, `ghr_*`, `github_pat_*`, `sk-svcacct-`,
     `sk-cp-`, `xox[baprs]-`, `ASIA*`. The historical leaks were 5×
     `ghs_*` (App installation tokens) + 1× `github_pat_*` — none of
     which the local hook would have caught even if it ran.
  2. **`*.md` and `docs/` were skip-listed.** The leaked tokens lived
     in `tick-reflections-temp.md`, `qa-audit-2026-04-21.md`, and
     `docs/incidents/INCIDENT_LOG.md` — exactly the file types the
     skip-list excluded. The hook ran and silently passed.

This commit:

- Replaces the local hook's hard-coded inline regex with the canonical
  13-pattern array (byte-aligned with `.github/workflows/secret-scan.yml`
  and the workspace runtime's `pre-commit-checks.sh`).
- Removes the `\.md$|docs/` skip — keeps only binary, lockfile, and
  hook-self exclusions.
- Adds the local hook to `lint_secret_pattern_drift.py` as an in-repo
  consumer (read-from-disk, no network — the hook lives in the same
  checkout the lint runs against). Drift now fails the lint when
  canonical changes without the local hook updating in lockstep.
- Adds `.githooks/pre-commit` to the drift-lint workflow's path
  filter so consumer-side edits also trigger the lint.
- Adopts the canonical's "don't echo the matched value" defense (the
  prior version would have round-tripped a leaked credential into
  scrollback / CI logs).

Verified: `python3 .github/scripts/lint_secret_pattern_drift.py`
reports both consumers aligned at 13 patterns. The hook's existing
six other gates (canvas 'use client', dark theme, SQL injection,
go-build, etc.) are untouched.

Companion change (already applied via API, no diff here):
`Scan diff for credential-shaped strings` is now in the required-checks
list on both `staging` and `main` branch protection — was previously a
soft gate (workflow ran, exited 1, but didn't block merge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:47:56 -07:00
Hongming Wang 435e13e57e Merge pull request #2504 from Molecule-AI/fix/restart-stop-retry-then-flag
fix(restart): retry cpProv.Stop with backoff + flag exhaustion as LEAK-SUSPECT
2026-05-02 06:40:58 +00:00
Hongming Wang f18ee8598a fix(restart): retry cpProv.Stop with backoff + flag exhaustion as LEAK-SUSPECT
Both restart paths (interactive Restart handler + auto-restart's
stopForRestart) used to log-and-continue on cpProv.Stop failure. After
PR #2500 made CPProvisioner.Stop surface CP non-2xx as an error, those
paths became the actual leak generator: every transient CP/AWS hiccup =
one orphan EC2 alongside the freshly provisioned one. The 13 zombie
workspace EC2s on demo-prep staging traced to this exact path.

Adds cpStopWithRetry helper with bounded exponential backoff (3 attempts,
1s/2s/4s). Different policy from workspace_crud.go's Delete handler:
Delete returns 500 to the client on Stop failure (loud-fail-and-block —
user asked to destroy, silent leak unacceptable), whereas Restart's
contract is "make the workspace alive again" — refusing to reprovision
strands the user with a dead workspace. So this helper retries to absorb
transient failures, then on exhaustion emits a structured `LEAK-SUSPECT`
log line for the (forthcoming) CP-side workspace orphan reconciler to
correlate. Caller proceeds to reprovision regardless.

ctx-cancel exits the retry early without sleeping the backoff (matters
during shutdown drain); the cancel path emits a distinct log line and
deliberately does NOT emit LEAK-SUSPECT — operator-cancel and
retry-exhaustion are different signals and conflating them would noise
up the orphan-reconciler queue with workspaces we never had a chance to
retry.

Tests: 5 behavior tests covering every branch (no-op, first-try success,
eventual success, exhaustion, ctx-cancel) + 1 AST gate that pins the
helper-only invariant (any future inline `h.cpProv.Stop(...)` in
workspace_restart.go fires the gate, mutation-tested).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:36:38 -07:00
Hongming Wang d64570a665 Merge pull request #2502 from Molecule-AI/fix/redeploy-main-use-staging-sha-tag
fix(redeploy-main): pull staging-<head_sha> instead of stale :latest
2026-05-02 06:30:32 +00:00
github-actions[bot] 2447c3da11 Merge pull request #2501 from Molecule-AI/staging
staging → main: auto-promote 23ee9b5
2026-05-01 23:27:52 -07:00
Hongming Wang 115f1f5e64 fix(redeploy-main): pull staging-<head_sha> instead of stale :latest
Auto-trigger from publish-workspace-server-image now resolves
target_tag to the just-published `staging-<short_head_sha>` digest
instead of `:latest`. Bypasses the dead retag path that was leaving
prod tenants on a 4-day-old image.

The chain pre-fix:
  publish-image  → pushes :staging-<sha> + :staging-latest (NOT :latest)
  canary-verify  → soft-skips (CANARY_TENANT_URLS unset, fleet not stood up)
  promote-latest → manual workflow_dispatch only, last run 2026-04-28
  redeploy-main  → pulls :latest → 2026-04-28 digest → all 3 tenants STALE

Today's incident:
  e7375348 (main) → publish-image green → redeploy fired → tenants
  pulled :latest (76c604fb digest from prior canary-verified state) →
  hongming /buildinfo returned 76c604fb instead of e7375348 → verify
  step correctly flagged 3/3 STALE → workflow failed.

Today's PRs (#2473 smoke wedge, #2487 panic recovery, #2496 sweeper
followups) shipped to GHCR as :staging-<sha> but never reached prod.

Fix:
  - workflow_dispatch input default '' (was 'latest'); empty input
    triggers auto-compute path
  - new "Compute target tag" step resolves:
    1. operator-supplied input → verbatim (rollback / pin)
    2. else → staging-<short_head_sha> (auto)
  - verify step's operator-pin detection now allows
    staging-<short_head_sha> as a non-pin (verification still runs)

When canary fleet is real, this workflow should chain on
canary-verify completion (workflow_run from canary-verify, gated on
promote-to-latest success) instead of publish-image — separate,
smaller PR. Today's fix unblocks prod deploys without that
prerequisite.

Companion: promote-latest.yml dispatched 2026-05-02 against
e7375348 to unstick existing prod tenants. This PR prevents
recurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:17:59 -07:00
Hongming Wang 23ee9b5e53 Merge pull request #2500 from Molecule-AI/fix/cp-provisioner-stop-status-check
fix(cp-provisioner): surface CP non-2xx on Stop to plug EC2 leak
2026-05-02 06:02:08 +00:00
Hongming Wang 5167e482d0 fix(cp-provisioner): surface CP non-2xx on Stop to plug EC2 leak
http.Client.Do only errors on transport failure — a CP 5xx (AWS
hiccup, missing IAM, transient outage) was silently treated as
success. Workspace row then flipped to status='removed' and the EC2
stayed alive forever with no DB pointer (the "orphan EC2 on a
0-customer account" scenario flagged in workspace_crud.go #1843).
Found while triaging 13 zombie workspace EC2s on demo-prep staging.

Adds a status-code check that returns an error tagged with the
workspace ID + status + bounded body excerpt, so the existing
loud-fail path in workspace_crud.go's Delete handler can populate
stop_failures and surface a 500. Body read is io.LimitReader-capped
at 512 bytes to keep error logs sane during a CP outage.

Tests: 4 new (5xx surfaces, 4xx surfaces, 2xx variants 200/202/204
all succeed, long body is truncated). Test-first verified — the
first three fail on the buggy code and all four pass on the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:59:01 -07:00
176 changed files with 10617 additions and 2659 deletions
+45 -7
View File
@@ -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)
+1 -1
View File
@@ -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'
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -66,7 +66,7 @@ jobs:
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: |
@@ -98,7 +98,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 +165,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';
+2 -2
View File
@@ -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: |
@@ -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
+5 -5
View File
@@ -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).
@@ -332,7 +332,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:
+2 -2
View File
@@ -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
+1 -1
View File
@@ -89,7 +89,7 @@ jobs:
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secret present
run: |
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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'
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
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: |
+2 -2
View File
@@ -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";
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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: |
+3 -3
View File
@@ -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
+8 -2
View File
@@ -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,13 @@ 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"
# Source of truth: manifest.json workspace_templates (PR #2536 pruned
# to 4 actively-supported runtimes: claude-code, hermes, openclaw, codex).
# Removed langgraph/crewai/autogen/deepagents/gemini-cli (deprecated, no
# shipping images); added codex (had been missing since #2512).
# Long-term: derive this list from manifest.json so the cascade can't
# drift again — tracked in RFC #388 as a Phase-1 invariant.
TEMPLATES="claude-code hermes openclaw codex"
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
+3 -3
View File
@@ -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}`;
+58 -9
View File
@@ -34,10 +34,24 @@ 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
@@ -91,12 +105,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,7 +147,7 @@ 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' }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongmingwang' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
@@ -209,7 +251,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 +260,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,44 @@ 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 e2e-* tenants (saas/canvas/ext) 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.
#
# Filter: if HTTP=500/ok=false AND every failed slug matches
# ^e2e-, treat as soft-warn and let the verify step downstream
# handle the unreachable-vs-stale distinction (it already knows
# the difference per #2402). Any non-e2e-* 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)
NON_E2E_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -v '^e2e-' || true)
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
: # happy path — fall through to verification
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_E2E_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -c '^e2e-' || true)
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is e2e-* ephemeral — 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_E2E_FAILED" ]; then
echo "::error::non-e2e tenant(s) failed:"
printf '%s\n' "$NON_E2E_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
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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:
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+129
View File
@@ -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
+1 -1
View File
@@ -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
+17 -5
View File
@@ -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
+1 -1
View File
@@ -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'
+2 -3
View File
@@ -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",
+389 -984
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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 -2
View File
@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};
+116 -13
View File
@@ -1,28 +1,130 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/*
* 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 +160,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 +172,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
View File
@@ -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>
);
+27 -27
View File
@@ -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>
+14 -14
View File
@@ -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="fixed inset-0 flex items-center justify-center bg-surface">
<div 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&apos;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&apos;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>
+13 -13
View File
@@ -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>
+3 -3
View File
@@ -61,13 +61,13 @@ 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
@@ -80,7 +80,7 @@ export function ApprovalBanner() {
<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"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded-lg text-ink-mid transition-colors"
>
Deny
</button>
+20 -20
View File
@@ -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>
+1 -1
View File
@@ -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
+7 -7
View File
@@ -80,14 +80,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 +104,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 +114,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 +129,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-zinc-500/70"
>
</button>
+6 -6
View File
@@ -112,7 +112,7 @@ 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>
@@ -120,19 +120,19 @@ export function BundleDropZone() {
{/* Visual overlay when dragging */}
{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="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 && (
<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="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 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>
<span className="text-sm text-ink">Importing bundle...</span>
</div>
)}
+4 -4
View File
@@ -244,11 +244,11 @@ 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"
nodes={nodes}
@@ -276,11 +276,11 @@ function CanvasInner() {
color="#27272a"
/>
<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"
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(node) => {
// Parents show as a filled region — hierarchy visible at
+14 -14
View File
@@ -102,7 +102,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 +110,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 +128,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 +140,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>
);
+6 -6
View File
@@ -96,7 +96,7 @@ export function ConfirmDialog({
? "bg-red-600 hover:bg-red-500 text-white"
: confirmVariant === "warning"
? "bg-amber-600 hover:bg-amber-500 text-white"
: "bg-blue-600 hover:bg-blue-500 text-white";
: "bg-accent-strong hover:bg-accent 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 +111,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-card border border-line rounded-lg transition-colors"
>
Cancel
</button>
+11 -11
View File
@@ -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,7 @@ 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"
className="text-ink-mid hover:text-ink text-sm px-2"
>
</button>
@@ -121,14 +121,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 +136,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,7 +144,7 @@ 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"
@@ -155,7 +155,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-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-card border border-line rounded-lg transition-colors"
>
Copy
</button>
@@ -163,7 +163,7 @@ 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"
className="px-3 py-1.5 text-[11px] text-ink-mid bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Close
</button>
+7 -7
View File
@@ -287,24 +287,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"
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: contextMenu.x, top: 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
@@ -316,8 +316,8 @@ export function ContextMenu() {
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 ${
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>
+7 -7
View File
@@ -103,21 +103,21 @@ export function CookieConsent() {
aria-modal="true"
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 &amp; 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 hover:text-accent"
target="_blank"
rel="noreferrer"
>
@@ -130,14 +130,14 @@ 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"
>
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"
>
Accept all
</button>
+29 -29
View File
@@ -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-strong hover:bg-accent active:bg-accent-strong 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">
<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&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div>
</div>
@@ -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-zinc-600 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-zinc-600 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-card text-sm rounded-lg text-ink-mid transition-colors">
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-strong hover:bg-accent active:bg-accent-strong text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
>
{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-zinc-500 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,7 +122,7 @@ 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>
@@ -133,19 +133,19 @@ export function DeleteCascadeConfirmDialog({
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 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
/>
<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"
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel
</button>
@@ -156,7 +156,7 @@ export function DeleteCascadeConfirmDialog({
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
${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-900/30 text-bad/40 cursor-not-allowed"
}`}
>
Delete All
+17 -17
View File
@@ -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">&#8984;K</kbd> to search</span>
<span className="text-ink-soft">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">&#8984;K</kbd> to search</span>
</div>
</div>
</div>
+7 -7
View File
@@ -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>
+14 -14
View File
@@ -113,13 +113,13 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<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&apos;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 +127,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
@@ -150,8 +150,8 @@ 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"
@@ -226,7 +226,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<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&apos;ve saved it close
</button>
@@ -252,16 +252,16 @@ 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>
@@ -283,9 +283,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 +293,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>
+14 -14
View File
@@ -65,7 +65,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 transition-[left,colors] duration-200`}
>
<span aria-hidden="true" className="text-[10px]"></span>
Legend
@@ -74,15 +74,15 @@ 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"
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-ink-soft hover:text-ink transition-colors"
>
×
</button>
@@ -90,7 +90,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 +100,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" />
<TierItem tier={3} label="Full Access" color="text-warm bg-amber-950/40 border-amber-700/30" />
</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 +126,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 +135,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 +144,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>
);
}
+40 -40
View File
@@ -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>
+146 -121
View File
@@ -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>
+7 -7
View File
@@ -132,10 +132,10 @@ 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">
<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"
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
@@ -162,17 +162,17 @@ export function OnboardingWizard() {
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"
>
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 +181,7 @@ 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"
className="flex-1 px-3 py-1.5 bg-accent-strong/90 hover:bg-accent rounded-lg text-[11px] font-medium text-white transition-colors"
>
{step === "welcome"
? "Create Workspace"
@@ -199,7 +199,7 @@ 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"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card rounded-lg text-[11px] text-ink-mid transition-colors"
>
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-card text-ink-mid"
>
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-strong hover:bg-accent 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-strong hover:bg-accent 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-strong hover:bg-accent 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>
)}
+11 -11
View File
@@ -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>
+14 -14
View File
@@ -92,12 +92,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 +113,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-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus:outline-none 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 +126,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 +139,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 +147,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 +164,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>
+19 -19
View File
@@ -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
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/40 border-b-2 border-accent"
: "text-ink-soft hover:text-ink hover:bg-surface-card/40"
}`}
>
<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 (
+27 -27
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-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>
+6 -6
View File
@@ -77,15 +77,15 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
<>
{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">
<div aria-hidden="true" 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"
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 &amp; conditions</h2>
<p className="mt-3 text-sm text-zinc-300">
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2>
<p className="mt-3 text-sm text-ink-mid">
Before you create an organization, please review our{" "}
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
Terms of Service
@@ -96,10 +96,10 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-zinc-500">
<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>
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
{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"
+81
View File
@@ -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>
);
}
+3 -3
View File
@@ -44,7 +44,7 @@ export function Toaster() {
? "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"
}`;
const pos =
@@ -66,7 +66,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="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</button>
@@ -94,7 +94,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="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</button>
+30 -26
View File
@@ -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-soft" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-soft 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-soft"> + {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>
@@ -175,10 +176,10 @@ export function Toolbar() {
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>
@@ -194,10 +195,10 @@ export function Toolbar() {
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>
@@ -217,8 +218,8 @@ export function Toolbar() {
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 ${
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-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-accent"
: "bg-surface-card/50 hover:bg-surface-card/50 border-line/40 text-ink-soft hover:text-ink-mid"
}`}
>
{/* 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/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
>
{/* 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/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
>
<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/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
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-soft hover:text-ink-mid transition-colors"
>
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-soft" 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-ink-soft" 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-ink-soft" 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-soft">{text}</p>
</div>
);
}
+2 -2
View File
@@ -66,10 +66,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>,
+37 -37
View File
@@ -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-soft bg-surface-card" };
// 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,7 +173,7 @@ 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>
@@ -213,7 +213,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
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/60 border border-line/30">
{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/80 bg-emerald-950/30 border-emerald-800/30"
: "text-ink-mid bg-surface-card/60 border-line/40"
}`}
>
{skill}
</span>
))}
{skills.length > 4 && (
<span className="text-[10px] text-zinc-500 self-center">
<span className="text-[10px] text-ink-soft 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,7 +274,7 @@ 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-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-accent/70 focus-visible:outline-none"
>
<span className="text-[10px]"></span>
<span className="text-[10px] text-sky-300/80">Restart to apply changes</span>
@@ -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 === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-sky-400" :
"text-zinc-500"
"text-ink-soft"
}`}>
{statusCfg.label}
</div>
@@ -297,7 +297,7 @@ 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">
<span className="text-[10px] text-warm/80 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/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
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-soft bg-surface-card" };
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,7 +402,7 @@ 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>
@@ -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-soft hover:text-sky-400 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-soft 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/70 bg-emerald-950/20 border-emerald-800/20"
: "text-ink-soft bg-surface-card/40 border-line/30"
}`}
>
{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 === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-sky-400" :
"text-zinc-500"
"text-ink-soft"
}`}>
{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} />
+8 -8
View File
@@ -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>
);
}
@@ -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");
});
@@ -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"],
});
});
});
+2 -2
View File
@@ -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");
});
+17 -17
View File
@@ -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 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>
+15 -15
View File
@@ -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 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>
+40 -40
View File
@@ -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,20 @@ 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
onClick={loadActivities}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[11px] rounded text-zinc-300"
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[11px] rounded text-ink-mid"
>
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 +125,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 +184,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 +195,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 +205,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 +236,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 +247,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 +255,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 +278,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 +298,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 +326,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 +369,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 +380,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 +391,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>
+16 -16
View File
@@ -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>
+34 -34
View File
@@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) {
return (
<div className="p-4 text-zinc-500 text-xs">Loading channels...</div>
<div className="p-4 text-ink-soft text-xs">Loading channels...</div>
);
}
@@ -250,33 +250,33 @@ export function ChannelsTab({ workspaceId }: Props) {
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-zinc-300 tracking-wide uppercase">
<h3 className="text-xs font-semibold text-ink-mid tracking-wide uppercase">
Channels
</h3>
<button
onClick={() => setShowForm(!showForm)}
className="text-[10px] px-2.5 py-1 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition"
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
>
{showForm ? "Cancel" : "+ Connect"}
</button>
</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>
)}
{/* Create form — schema-driven */}
{showForm && (
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
<div>
<label htmlFor={platformId} className="text-[10px] text-zinc-500 block mb-1">Platform</label>
<label htmlFor={platformId} className="text-[10px] text-ink-soft block mb-1">Platform</label>
<select
id={platformId}
value={formType}
onChange={(e) => setFormType(e.target.value)}
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300"
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid"
>
{adapters.map((a) => (
<option key={a.type} value={a.type}>{a.display_name}</option>
@@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<button
onClick={handleDiscover}
disabled={discovering || !formValues["bot_token"]}
className="text-[10px] px-2 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition disabled:opacity-40"
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
>
{discovering ? "Detecting..." : "Detect Chats"}
</button>
@@ -318,21 +318,21 @@ export function ChannelsTab({ workspaceId }: Props) {
{discoveredChats.map((chat) => (
<label
key={chat.chat_id}
className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900/50 rounded border border-zinc-700/50 cursor-pointer hover:bg-zinc-800/50"
className="flex items-center gap-2 px-2 py-1.5 bg-surface-sunken/50 rounded border border-line/50 cursor-pointer hover:bg-surface-card/50"
>
<input
type="checkbox"
checked={selectedChats.has(chat.chat_id)}
onChange={() => toggleChat(chat.chat_id)}
className="rounded border-zinc-600"
className="rounded border-line"
/>
<span className="text-xs text-zinc-300">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-zinc-500 ml-auto">{chat.type} {chat.chat_id}</span>
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-ink-soft ml-auto">{chat.type} {chat.chat_id}</span>
</label>
))}
<button
onClick={() => setShowManualInput(!showManualInput)}
className="text-[10px] text-blue-400 hover:underline"
className="text-[10px] text-accent hover:underline"
>
{showManualInput ? "hide manual input" : "edit manually"}
</button>
@@ -347,26 +347,26 @@ export function ChannelsTab({ workspaceId }: Props) {
)}
<div>
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
</label>
<input
id={allowedUsersId}
value={formAllowedUsers}
onChange={(e) => setFormAllowedUsers(e.target.value)}
placeholder="123456789, 987654321"
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600"
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
/>
<p className="text-[11px] text-zinc-500 mt-0.5">
<p className="text-[11px] text-ink-soft mt-0.5">
Platform-specific user IDs. Leave empty to allow everyone.
</p>
</div>
{formError && (
<p className="text-[10px] text-red-400">{formError}</p>
<p className="text-[10px] text-bad">{formError}</p>
)}
<button
onClick={handleCreate}
className="w-full text-xs py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-white transition"
className="w-full text-xs py-1.5 rounded bg-accent-strong hover:bg-accent text-white transition"
>
Connect Channel
</button>
@@ -376,8 +376,8 @@ export function ChannelsTab({ workspaceId }: Props) {
{/* Channel list */}
{channels.length === 0 && !showForm && (
<div className="text-center py-8">
<p className="text-zinc-500 text-xs">No channels connected</p>
<p className="text-zinc-600 text-[10px] mt-1">
<p className="text-ink-soft text-xs">No channels connected</p>
<p className="text-ink-soft text-[10px] mt-1">
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
</p>
</div>
@@ -386,19 +386,19 @@ export function ChannelsTab({ workspaceId }: Props) {
{channels.map((ch) => (
<div
key={ch.id}
className="p-3 bg-zinc-800/30 rounded border border-zinc-700/40 space-y-2"
className="p-3 bg-surface-card/30 rounded border border-line/40 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
ch.enabled ? "bg-emerald-500" : "bg-zinc-600"
ch.enabled ? "bg-emerald-500" : "bg-surface-card"
}`}
/>
<span className="text-xs font-medium text-zinc-200">
<span className="text-xs font-medium text-ink">
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span>
<span className="text-[10px] text-zinc-500">
<span className="text-[10px] text-ink-soft">
{ch.config.chat_id || ch.config.channel_id || ""}
</span>
</div>
@@ -406,7 +406,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<button
onClick={() => handleTest(ch)}
disabled={testing === ch.id}
className="text-[10px] px-2 py-0.5 rounded bg-zinc-700/50 text-zinc-400 hover:text-zinc-200 transition disabled:opacity-50"
className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50"
>
{testing === ch.id ? "Sent!" : "Test"}
</button>
@@ -414,21 +414,21 @@ export function ChannelsTab({ workspaceId }: Props) {
onClick={() => handleToggle(ch)}
className={`text-[10px] px-2 py-0.5 rounded transition ${
ch.enabled
? "bg-emerald-900/30 text-emerald-400 hover:bg-emerald-900/50"
: "bg-zinc-700/50 text-zinc-500 hover:text-zinc-300"
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-soft hover:text-ink-mid"
}`}
>
{ch.enabled ? "On" : "Off"}
</button>
<button
onClick={() => setPendingDelete(ch)}
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-red-400 hover:bg-red-900/40 transition"
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition"
>
Remove
</button>
</div>
</div>
<div className="flex items-center gap-4 text-[10px] text-zinc-500">
<div className="flex items-center gap-4 text-[10px] text-ink-soft">
<span>{ch.message_count} messages</span>
<span>Last: {relativeTime(ch.last_message_at)}</span>
{ch.allowed_users.length > 0 && (
@@ -467,12 +467,12 @@ function SchemaField({
}) {
const inputId = useId();
const common =
"w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300 placeholder-zinc-600";
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
return (
<div>
<label htmlFor={inputId} className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={inputId} className="text-[10px] text-ink-soft block mb-1">
{field.label}
{!field.required && <span className="text-zinc-600"> (optional)</span>}
{!field.required && <span className="text-ink-soft"> (optional)</span>}
</label>
{field.type === "textarea" ? (
<textarea
@@ -495,7 +495,7 @@ function SchemaField({
)}
{renderExtras?.()}
{field.help && (
<p className="text-[11px] text-zinc-500 mt-0.5">{field.help}</p>
<p className="text-[11px] text-ink-soft mt-0.5">{field.help}</p>
)}
</div>
);
+31 -25
View File
@@ -32,6 +32,11 @@ interface A2AFileRef {
bytes?: string;
size?: number;
}
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
// Pydantic union (TextPart | FilePart | DataPart). The flat
// protobuf shape `{url, filename, mediaType}` is rejected at the
// request boundary with `Field required` errors — keep this
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
interface A2APart {
kind: string;
text?: string;
@@ -157,7 +162,7 @@ export function ChatTab({ workspaceId, data }: Props) {
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
<div
role="tablist"
className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0"
className="flex border-b border-line/40 bg-surface-sunken/30 px-2 shrink-0"
onKeyDown={(e) => {
const tabs: ChatSubTab[] = ["my-chat", "agent-comms"];
const idx = tabs.indexOf(subTab);
@@ -174,8 +179,8 @@ export function ChatTab({ workspaceId, data }: Props) {
onClick={() => setSubTab("my-chat")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "my-chat"
? "text-zinc-200 border-b-2 border-blue-500"
: "text-zinc-500 hover:text-zinc-300"
? "text-ink border-b-2 border-accent"
: "text-ink-soft hover:text-ink-mid"
}`}
>
My Chat
@@ -189,8 +194,8 @@ export function ChatTab({ workspaceId, data }: Props) {
onClick={() => setSubTab("agent-comms")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "agent-comms"
? "text-zinc-200 border-b-2 border-blue-500"
: "text-zinc-500 hover:text-zinc-300"
? "text-ink border-b-2 border-accent"
: "text-ink-soft hover:text-ink-mid"
}`}
>
Agent Comms
@@ -502,6 +507,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
// A2A parts: text part (if any) + file parts (per attachment). The
// agent sees both in a single turn, matching the A2A spec shape.
// Wire shape is v0 — see A2APart definition above.
const parts: A2APart[] = [];
if (text) parts.push({ kind: "text", text });
for (const att of uploaded) {
@@ -720,10 +726,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
>
{dragOver && (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-blue-500/10 border-2 border-dashed border-blue-400 rounded pointer-events-none"
className="absolute inset-0 z-20 flex items-center justify-center bg-accent/10 border-2 border-dashed border-blue-400 rounded pointer-events-none"
aria-live="polite"
>
<div className="bg-zinc-900/90 border border-blue-400/50 rounded-lg px-4 py-2 text-xs text-blue-200">
<div className="bg-surface-sunken/90 border border-blue-400/50 rounded-lg px-4 py-2 text-xs text-blue-200">
Drop to attach
</div>
</div>
@@ -731,14 +737,14 @@ function MyChatPanel({ workspaceId, data }: Props) {
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && (
<div className="text-xs text-zinc-500 text-center py-4">Loading chat history...</div>
<div className="text-xs text-ink-soft text-center py-4">Loading chat history...</div>
)}
{!loading && loadError !== null && messages.length === 0 && (
<div
role="alert"
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
>
<p className="text-[11px] text-red-400 mb-1.5">
<p className="text-[11px] text-bad mb-1.5">
Failed to load chat history: {loadError}
</p>
<button
@@ -751,14 +757,14 @@ function MyChatPanel({ workspaceId, data }: Props) {
setLoading(false);
});
}}
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-red-300 hover:bg-red-700/50 transition-colors"
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
>
Retry
</button>
</div>
)}
{!loading && loadError === null && messages.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 messages yet. Send a message to start chatting with this agent.
</div>
)}
@@ -767,10 +773,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user"
? "bg-blue-600/30 text-blue-100 border border-blue-500/20"
? "bg-accent-strong/30 text-blue-100 border border-accent/20"
: msg.role === "system"
? "bg-red-900/30 text-red-200 border border-red-800/30"
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
: "bg-surface-card/80 text-ink border border-line/30"
}`}
>
{msg.content && (
@@ -790,7 +796,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
))}
</div>
)}
<div className="text-[9px] text-zinc-500 mt-1">
<div className="text-[9px] text-ink-soft mt-1">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
@@ -803,8 +809,8 @@ function MyChatPanel({ workspaceId, data }: Props) {
without locking the Send button on a stale currentTask). */}
{(sending || !!data.currentTask) && (
<div className="flex justify-start">
<div className="bg-zinc-800/50 border border-zinc-700/30 rounded-lg px-3 py-2 max-w-[85%]">
<div className="flex items-center gap-2 text-xs text-zinc-400">
<div className="bg-surface-card/50 border border-line/30 rounded-lg px-3 py-2 max-w-[85%]">
<div className="flex items-center gap-2 text-xs text-ink-mid">
<span className="flex gap-0.5">
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full motion-safe:animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full motion-safe:animate-bounce" style={{ animationDelay: "150ms" }} />
@@ -813,10 +819,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
{thinkingElapsed}s
</div>
{activityLog.length > 0 && (
<div className="mt-1.5 text-[9px] text-zinc-500 space-y-0.5">
<div className="text-zinc-400">Processing with {runtimeDisplayName(data.runtime)}...</div>
<div className="mt-1.5 text-[9px] text-ink-soft space-y-0.5">
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
{activityLog.map((line, i) => (
<div key={line + i} className="pl-2 border-l border-zinc-700"> {line}</div>
<div key={line + i} className="pl-2 border-l border-line"> {line}</div>
))}
</div>
)}
@@ -830,11 +836,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
{error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-400">{error}</span>
<span className="text-[10px] text-bad">{error}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
className="text-[11px] px-2 py-0.5 bg-red-800/40 text-red-300 rounded hover:bg-red-700/50"
className="text-[11px] px-2 py-0.5 bg-red-800/40 text-bad rounded hover:bg-red-700/50"
>
Restart
</button>
@@ -844,7 +850,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
)}
{/* Input */}
<div className="p-3 border-t border-zinc-800">
<div className="p-3 border-t border-line">
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{pendingFiles.map((f, i) => (
@@ -870,7 +876,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
disabled={!agentReachable || sending || uploading}
aria-label="Attach file"
title="Attach file"
className="p-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors shrink-0 disabled:opacity-40"
className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M11 6.5 7 10.5a2 2 0 1 0 2.8 2.8l4-4a3.5 3.5 0 0 0-5-5l-4.5 4.5a5 5 0 0 0 7 7l4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
@@ -890,12 +896,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
placeholder={agentReachable ? "Send a message... (Shift+Enter for new line, paste images to attach)" : `Agent is ${data.status}`}
disabled={!agentReachable || sending}
rows={1}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-blue-500 resize-none disabled:opacity-50"
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-zinc-500 focus:outline-none focus:border-accent resize-none disabled:opacity-50"
/>
<button
onClick={sendMessage}
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
>
{uploading ? "Uploading…" : "Send"}
</button>
+304 -170
View File
@@ -1,11 +1,17 @@
"use client";
import { useState, useEffect, useCallback, useRef, useId } from "react";
import { useState, useEffect, useCallback, useRef, useId, useMemo } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
import { parseYaml, toYaml } from "./config/yaml-utils";
import { SecretsSection } from "./config/secrets-section";
import {
ProviderModelSelector,
buildProviderCatalog,
findProviderForModel,
type SelectorValue,
} from "../ProviderModelSelector";
interface Props {
workspaceId: string;
@@ -47,37 +53,37 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
return (
<Section title="Agent Card" defaultOpen={false}>
{loading ? (
<div className="text-[10px] text-zinc-500">Loading...</div>
<div className="text-[10px] text-ink-soft">Loading...</div>
) : editing ? (
<div className="space-y-2">
<textarea
aria-label="Agent card JSON editor"
value={draft} onChange={(e) => setDraft(e.target.value)}
spellCheck={false} rows={12}
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-[10px] font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
className="w-full bg-surface-card border border-line rounded p-2 text-[10px] font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-red-400">{error}</div>}
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
<div className="flex gap-2">
<button type="button" onClick={handleSave} disabled={saving}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white disabled:opacity-50">
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
<button type="button" onClick={() => setEditing(false)}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
</div>
</div>
) : (
<div>
{card ? (
<pre className="text-[9px] text-zinc-400 bg-zinc-800/50 rounded p-2 overflow-x-auto max-h-48 border border-zinc-700/50">
<pre className="text-[9px] text-ink-mid bg-surface-card/50 rounded p-2 overflow-x-auto max-h-48 border border-line/50">
{JSON.stringify(card, null, 2)}
</pre>
) : (
<div className="text-[10px] text-zinc-500">No agent card</div>
<div className="text-[10px] text-ink-soft">No agent card</div>
)}
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-green-400">Updated</div>}
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
className="mt-2 text-[10px] text-blue-400 hover:text-blue-300">Edit Agent Card</button>
className="mt-2 text-[10px] text-accent hover:text-accent">Edit Agent Card</button>
</div>
)}
</Section>
@@ -185,6 +191,25 @@ export function ConfigTab({ workspaceId }: Props) {
// data, written into /configs/config.yaml on next provision too).
const [provider, setProvider] = useState("");
const [originalProvider, setOriginalProvider] = useState("");
// Track the model the form first rendered, so handleSave can detect
// whether the user actually changed it (vs. only edited tier/skills/etc).
// Two field sources contribute:
// 1. wsMetadataModel — workspace_secrets.MODEL_PROVIDER (DB)
// 2. parsed.runtime_config.model — the template's YAML default
// Whichever was the live runtime value at load time is what currentModelId
// will display, and it's the value Save must diff against.
//
// Why not just diff the YAML directly: after loadConfig mirrors
// wsMetadataModel into runtime_config.model for display, the YAML diff
// is always non-zero on a no-op save, fires PUT /model, and triggers
// an auto-restart for unrelated edits. Why not diff against
// wsMetadataModel alone: on a hermes workspace where MODEL_PROVIDER
// was never set (pre-#240 workspaces, or workspaces created via direct
// API without going through the picker), wsMetadataModel="" but the
// form shows the YAML default — diffing against "" makes any first
// save propagate-and-restart even when the user didn't touch the model.
// Capturing the actual rendered value covers both.
const [originalModel, setOriginalModel] = useState("");
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
@@ -214,6 +239,11 @@ export function ConfigTab({ workspaceId }: Props) {
const m = await api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`);
wsMetadataModel = (m.model || "").trim();
} catch { /* non-fatal */ }
// originalModel is set further down once the YAML has been parsed —
// we want it to reflect what the form ACTUALLY rendered, which may
// be the YAML's runtime_config.model fallback when MODEL_PROVIDER
// is empty. Setting it here from wsMetadataModel alone would be
// wrong for hermes/pre-#240 workspaces.
// Load explicit provider override (Option B PR-5). Endpoint returns
// {provider: "", source: "default"} when no override is set, so the
@@ -243,8 +273,24 @@ export function ConfigTab({ workspaceId }: Props) {
// form doesn't contradict the node badge (issue: badge=T3, form=T2).
const merged = { ...DEFAULT_CONFIG, ...parsed } as ConfigData;
if (wsMetadataRuntime) merged.runtime = wsMetadataRuntime;
if (wsMetadataModel) merged.model = wsMetadataModel;
if (wsMetadataModel) {
// Single source of truth: MODEL_PROVIDER (DB) is the live runtime
// value. Override BOTH top-level + nested runtime_config.model so
// currentModelId (which reads runtime_config.model first) doesn't
// silently fall through to the template default. Without the
// nested override, a workspace deployed with `MiniMax-M2` shows
// the template's `runtime_config.model: sonnet` in the UI even
// though the container env (and chat) actually use MiniMax-M2.
merged.model = wsMetadataModel;
merged.runtime_config = { ...(merged.runtime_config ?? {}), model: wsMetadataModel };
}
if (wsMetadataTier !== null) merged.tier = wsMetadataTier;
// Snapshot the rendered model so handleSave's diff stays scoped to
// user-initiated changes. mirrors the read precedence in
// currentModelId so an unrelated save (tier change) doesn't fire
// a /model PUT just because MODEL_PROVIDER was empty and the form
// showed the YAML default.
setOriginalModel(merged.runtime_config?.model || merged.model || "");
setConfig(merged);
} catch {
// No platform-managed config.yaml. Some runtimes (hermes, external)
@@ -259,8 +305,17 @@ export function ConfigTab({ workspaceId }: Props) {
...DEFAULT_CONFIG,
runtime: wsMetadataRuntime,
model: wsMetadataModel,
// Mirror the merged-path fix above — keep top-level + nested in
// sync so currentModelId picks up wsMetadataModel even when the
// form falls into the no-config.yaml branch (hermes/external).
...(wsMetadataModel ? { runtime_config: { model: wsMetadataModel } } : {}),
...(wsMetadataTier !== null ? { tier: wsMetadataTier } : {}),
} as ConfigData);
// Same snapshot as the merged-path branch above. Falls back to
// empty string when neither MODEL_PROVIDER nor a YAML model was
// present; handleSave's `nextModel && ...` guard then skips the
// PUT correctly.
setOriginalModel(wsMetadataModel);
} finally {
setLoading(false);
}
@@ -298,19 +353,61 @@ export function ConfigTab({ workspaceId }: Props) {
// Models + env hints for the currently-selected runtime.
const selectedRuntime = runtimeOptions.find((o) => o.value === (config.runtime || "")) ?? null;
const availableModels: ModelSpec[] = selectedRuntime?.models ?? [];
// Provider suggestions: prefer the runtime's declarative providers
// list (sourced from its template config.yaml runtime_config.providers
// and surfaced via /templates), fall back to deriving from model slug
// prefixes when the template hasn't migrated to the explicit field
// yet. Either way the data flows from the adapter — no hardcoded
// canvas-side enum.
const providerSuggestions: string[] =
// Provider suggestions for the legacy free-text input fallback (used
// when /templates returned no models for this runtime, e.g. hermes
// workspaces). Prefer the runtime's declarative providers list,
// fall back to deriving from model-slug prefixes.
const providerSuggestionsList: string[] =
(selectedRuntime?.providers && selectedRuntime.providers.length > 0)
? selectedRuntime.providers
: deriveProvidersFromModels(availableModels);
const currentModelId = config.runtime_config?.model || config.model || "";
const currentModelSpec = availableModels.find((m) => m.id === currentModelId) ?? null;
// Vendor-aware catalog shared with the selector. Memoised so the
// catalog identity is stable across renders (selector relies on it).
const providerCatalog = useMemo(
() => buildProviderCatalog(availableModels),
[availableModels],
);
// Derive the selector's current value from the form state. Provider
// back-derivation prefers a vendor-key match against `provider`
// (Option B explicit override), falling back to the model's vendor
// bucket when no override is set.
const selectorValue: SelectorValue = useMemo(() => {
// 1. Prefer explicit vendor match (workspace_secrets MODEL_PROVIDER).
if (provider) {
const byVendor = providerCatalog.find((p) => p.vendor === provider);
if (byVendor) {
return {
providerId: byVendor.id,
model: currentModelId,
envVars: byVendor.envVars,
};
}
}
// 2. Back-derive from model id.
const matched = findProviderForModel(providerCatalog, currentModelId);
if (matched) {
return {
providerId: matched.id,
model: currentModelId,
envVars: matched.envVars,
};
}
// 3. Empty — user hasn't picked yet (or template has no models).
return { providerId: "", model: currentModelId, envVars: [] };
}, [provider, currentModelId, providerCatalog]);
const setSelectorValue = (_next: SelectorValue) => {
// Selector emits `next`; the actual writes happen in the onChange
// handler in JSX which calls setConfig + setProvider directly.
// This setter exists only to satisfy ProviderModelSelector's
// controlled-component contract (it always re-derives from props
// so the no-op identity is fine).
void _next;
};
const update = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
@@ -367,6 +464,15 @@ export function ConfigTab({ workspaceId }: Props) {
}
if (Object.keys(dbPatch).length > 0) {
await api.patch(`/workspaces/${workspaceId}`, dbPatch);
// Mirror the DB write into the canvas store node data so the
// header pill (TIER T2/T3, RUNTIME claude-code/hermes) and the
// node card update immediately. Without this push, the workspace
// row reflects the new tier but every UI surface that reads from
// useCanvasStore.nodes (header badge, ContextMenu, etc.) keeps
// showing the stale value until the next full hydrate. Bug
// surfaced 2026-05-03 — user picked T3, hit Save & Restart,
// database said tier=3, badge still said T2.
useCanvasStore.getState().updateNodeData(workspaceId, dbPatch);
}
// Model has its own endpoint (separate from the general workspace
@@ -388,21 +494,26 @@ export function ConfigTab({ workspaceId }: Props) {
// configured" error in the chat. Caught 2026-04-30 on hongmingwang
// hermes workspace 32993ee7-…cb9d75d112a5.
const nextModelRaw = (nextSource.runtime_config as Record<string, unknown> | undefined)?.model;
const oldModelRaw = (oldParsed.runtime_config as Record<string, unknown> | undefined)?.model;
const nextModel =
typeof nextModelRaw === "string" && nextModelRaw
? nextModelRaw
: typeof nextSource.model === "string"
? nextSource.model
: "";
const oldModel =
typeof oldModelRaw === "string" && oldModelRaw
? oldModelRaw
: (oldParsed.model as string) || "";
// Diff against the loaded MODEL_PROVIDER (the runtime source of
// truth), not the YAML's runtime_config.model. After loadConfig
// mirrors wsMetadataModel into runtime_config.model for display,
// nextModel always equals the loaded value on a no-op save —
// diffing against oldModelRaw (the unmirrored YAML default) would
// make every Save fire a /model PUT and trigger an auto-restart,
// even when the user only changed an unrelated field. Comparing
// against `originalModel` keeps the PUT scoped to actual user
// intent.
let modelSaveError: string | null = null;
if (nextModel && nextModel !== oldModel) {
if (nextModel && nextModel !== originalModel) {
try {
await api.put(`/workspaces/${workspaceId}/model`, { model: nextModel });
setOriginalModel(nextModel);
} catch (e) {
modelSaveError = e instanceof Error ? e.message : "Model update was rejected";
}
@@ -481,16 +592,16 @@ export function ConfigTab({ workspaceId }: Props) {
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
if (loading) {
return <div className="p-4 text-xs text-zinc-500">Loading config...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading config...</div>;
}
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/30">
<span className="text-[10px] text-zinc-500">config.yaml</span>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
<span className="text-[10px] text-ink-soft">config.yaml</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<span className="text-[9px] text-zinc-500">Raw YAML</span>
<span className="text-[9px] text-ink-soft">Raw YAML</span>
<input
type="checkbox"
checked={rawMode}
@@ -515,7 +626,7 @@ export function ConfigTab({ workspaceId }: Props) {
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
spellCheck={false}
className="w-full h-full min-h-[300px] bg-zinc-800 border border-zinc-700 rounded p-3 text-xs font-mono text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
className="w-full h-full min-h-[300px] bg-surface-card border border-line rounded p-3 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
</div>
) : (
@@ -523,24 +634,24 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<div>
<label htmlFor={descriptionId} className="text-[10px] text-zinc-500 block mb-1">Description</label>
<label htmlFor={descriptionId} className="text-[10px] text-ink-soft block mb-1">Description</label>
<textarea
id={descriptionId}
value={config.description}
onChange={(e) => update("description", e.target.value)}
rows={3}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 resize-none"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<div>
<label htmlFor={tierId} className="text-[10px] text-zinc-500 block mb-1">Tier</label>
<label htmlFor={tierId} className="text-[10px] text-ink-soft block mb-1">Tier</label>
<select
id={tierId}
value={config.tier}
onChange={(e) => update("tier", parseInt(e.target.value, 10))}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
>
<option value={1}>T1 Sandboxed</option>
<option value={2}>T2 Standard</option>
@@ -551,125 +662,148 @@ export function ConfigTab({ workspaceId }: Props) {
</Section>
<Section title="Runtime">
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
<select
id={runtimeId}
value={config.runtime || ""}
onChange={(e) => update("runtime", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
>
{runtimeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">
Model
{availableModels.length > 0 && (
<span className="ml-1 text-zinc-600">({availableModels.length} suggested)</span>
)}
</label>
<input
type="text"
list={availableModels.length > 0 ? `${runtimeId}-models` : undefined}
value={currentModelId}
onChange={(e) => {
const v = e.target.value;
setConfig((prev) => {
// If the new value exactly matches a known modelSpec id,
// swap required_env to that spec's list — but only when
// the current required_env is empty or was itself
// template-driven (i.e. matches the previous modelSpec's
// required_env). User-typed envs always win.
const nextSpec = availableModels.find((m) => m.id === v) ?? null;
const prevModelId = prev.runtime_config?.model || prev.model || "";
const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null;
const prevRequired = prev.runtime_config?.required_env ?? [];
const wasTemplateDriven =
prevRequired.length === 0 ||
(prevSpec?.required_env?.length
? prevRequired.length === prevSpec.required_env.length &&
prevRequired.every((e, i) => e === prevSpec.required_env![i])
: false);
const nextRequired =
nextSpec?.required_env?.length && wasTemplateDriven
? nextSpec.required_env
: prevRequired;
if (prev.runtime) {
return {
...prev,
runtime_config: {
...prev.runtime_config,
model: v,
...(nextSpec?.required_env?.length && wasTemplateDriven
? { required_env: nextRequired }
: {}),
},
};
}
return { ...prev, model: v };
});
}}
placeholder="e.g. anthropic:claude-sonnet-4-6"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500"
/>
{availableModels.length > 0 && (
<datalist id={`${runtimeId}-models`}>
{availableModels.map((m, i) => (
<option key={`${m.id}-${i}`} value={m.id}>{m.name || m.id}</option>
))}
</datalist>
)}
</div>
</div>
{/* Provider override (Option B PR-5). Free-text combobox so
operators can use any of the 30+ slugs hermes-agent's
derive-provider.sh recognizes — the suggestion list is
a hint, not a constraint. Empty = "auto-derive from
model slug prefix" which is correct for the common case
(model "anthropic:claude-opus-4-7" → provider derived
as "anthropic"). The override is needed when the model
alias has no clean vendor prefix (e.g. hermes default
"nousresearch/hermes-4-70b" → derive returns empty →
hermes errors "No LLM provider configured"). */}
<div>
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-zinc-500 block mb-1">
Provider
<span className="ml-1 text-zinc-600">
(override leave empty to auto-derive from model slug)
</span>
</label>
<input
id={`${runtimeId}-provider`}
type="text"
list={providerSuggestions.length > 0 ? `${runtimeId}-providers` : undefined}
value={provider}
onChange={(e) => setProvider(e.target.value.trim())}
placeholder={
providerSuggestions.length > 0
? `e.g. ${providerSuggestions.slice(0, 3).join(", ")} (empty = auto-derive)`
: "empty = auto-derive from model slug"
}
aria-label="LLM provider override"
data-testid="provider-input"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500"
/>
{providerSuggestions.length > 0 && (
<datalist id={`${runtimeId}-providers`}>
{providerSuggestions.map((p) => (
<option key={p} value={p} />
))}
</datalist>
)}
{provider && provider !== originalProvider && (
<p className="text-[10px] text-amber-500 mt-1">
Provider change workspace will auto-restart on Save.
</p>
)}
<label htmlFor={runtimeId} className="text-[10px] text-ink-soft block mb-1">Runtime</label>
<select
id={runtimeId}
value={config.runtime || ""}
onChange={(e) => update("runtime", e.target.value)}
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
>
{runtimeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Shared Provider→Model selector. Same component renders in
MissingKeysModal (deploy onboarding) so the dropdown UX is
identical across all three surfaces. Provider field maps
back into the workspace_secrets MODEL_PROVIDER override
— empty = "auto-derive from model slug" was the pre-PR-5
behavior; selecting any provider here writes LLM_PROVIDER
and triggers an auto-restart. */}
{availableModels.length > 0 ? (
<ProviderModelSelector
models={availableModels}
value={selectorValue}
onChange={(next) => {
setSelectorValue(next);
// Mirror selection into the config object the rest of
// the form / save handler still reads. Model lands in
// runtime_config.model when a runtime is set, else
// top-level model. required_env follows the selected
// provider's envVars when the existing required_env
// was template-driven (don't clobber user-typed envs).
setConfig((prev) => {
const v = next.model;
const prevModelId = prev.runtime_config?.model || prev.model || "";
const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null;
const prevRequired = prev.runtime_config?.required_env ?? [];
const wasTemplateDriven =
prevRequired.length === 0 ||
(prevSpec?.required_env?.length
? prevRequired.length === prevSpec.required_env.length &&
prevRequired.every((e, i) => e === prevSpec.required_env![i])
: false);
const nextRequired =
next.envVars.length > 0 && wasTemplateDriven
? next.envVars
: prevRequired;
if (prev.runtime) {
return {
...prev,
runtime_config: {
...prev.runtime_config,
model: v,
...(next.envVars.length > 0 && wasTemplateDriven
? { required_env: nextRequired }
: {}),
},
};
}
return { ...prev, model: v };
});
// Map vendor → workspace_secrets MODEL_PROVIDER value.
// Hermes-agent derive-provider.sh is the canonical
// recogniser, but we approximate by emitting the
// catalog vendor key (which matches our hermes
// provider taxonomy 1:1 for the slugs we ship).
if (next.providerId) {
const entry = providerCatalog.find((p) => p.id === next.providerId);
if (entry) setProvider(entry.vendor);
} else {
setProvider("");
}
}}
variant="grid"
idPrefix={runtimeId}
allowCustomModelEscape
/>
) : (
// Fallback when /templates didn't surface any models for
// this runtime — e.g. hermes workspaces that manage their
// own ~/.hermes/config.yaml. Power-user free-text inputs
// for both fields. Provider here writes through to the
// workspace_secrets MODEL_PROVIDER override.
<div className="space-y-3">
<div>
<label className="text-[10px] text-ink-soft block mb-1">Model</label>
<input
type="text"
value={currentModelId}
onChange={(e) => {
const v = e.target.value;
setConfig((prev) =>
prev.runtime
? { ...prev, runtime_config: { ...prev.runtime_config, model: v } }
: { ...prev, model: v },
);
}}
placeholder="e.g. anthropic:claude-sonnet-4-6"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink font-mono focus:outline-none focus:border-accent"
/>
</div>
<div>
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-soft block mb-1">
Provider
<span className="ml-1 text-ink-soft">
(override leave empty to auto-derive from model slug)
</span>
</label>
<input
id={`${runtimeId}-provider`}
type="text"
list={
providerSuggestionsList.length > 0
? `${runtimeId}-providers`
: undefined
}
value={provider}
onChange={(e) => setProvider(e.target.value.trim())}
placeholder={
providerSuggestionsList.length > 0
? `e.g. ${providerSuggestionsList.slice(0, 3).join(", ")} (empty = auto-derive)`
: "empty = auto-derive from model slug"
}
aria-label="LLM provider override"
data-testid="provider-input"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink font-mono focus:outline-none focus:border-accent"
/>
{providerSuggestionsList.length > 0 && (
<datalist id={`${runtimeId}-providers`}>
{providerSuggestionsList.map((p) => (
<option key={p} value={p} />
))}
</datalist>
)}
</div>
</div>
)}
{provider && provider !== originalProvider && (
<p className="text-[10px] text-warm mt-1">
Provider change workspace will auto-restart on Save.
</p>
)}
<TagList
label={
currentModelSpec?.required_env?.length &&
@@ -681,7 +815,7 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
/>
<p className="text-[10px] text-zinc-500 mt-1">
<p className="text-[10px] text-ink-soft mt-1">
This declares which env var <em>names</em> the workspace needs.
Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at
@@ -689,16 +823,16 @@ export function ConfigTab({ workspaceId }: Props) {
</p>
{currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
<div className="text-[10px] text-ink-soft mt-1 flex items-center gap-2">
<span>
Template suggests{" "}
<code className="text-zinc-400">{currentModelSpec.required_env.join(", ")}</code>{" "}
for <code className="text-zinc-400">{currentModelSpec.name || currentModelSpec.id}</code>.
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
for <code className="text-ink-mid">{currentModelSpec.name || currentModelSpec.id}</code>.
</span>
<button
type="button"
onClick={() => updateNested("runtime_config" as keyof ConfigData, "required_env", currentModelSpec.required_env)}
className="text-blue-400 hover:text-blue-300 underline"
className="text-accent hover:text-accent underline"
>
Apply
</button>
@@ -712,15 +846,15 @@ export function ConfigTab({ workspaceId }: Props) {
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
<Section title="Claude Settings" defaultOpen={false}>
<div>
<label htmlFor={effortId} className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={effortId} className="text-[10px] text-ink-soft block mb-1">
Effort
<span className="ml-1 text-zinc-600">(output_config.effort Opus 4.7+)</span>
<span className="ml-1 text-ink-soft">(output_config.effort Opus 4.7+)</span>
</label>
<select
id={effortId}
value={config.effort || ""}
onChange={(e) => update("effort", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
data-testid="effort-select"
>
<option value=""> unset (model default) </option>
@@ -732,9 +866,9 @@ export function ConfigTab({ workspaceId }: Props) {
</select>
</div>
<div>
<label htmlFor={taskBudgetId} className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-soft block mb-1">
Task Budget (tokens)
<span className="ml-1 text-zinc-600">(output_config.task_budget.total 0 = unset)</span>
<span className="ml-1 text-ink-soft">(output_config.task_budget.total 0 = unset)</span>
</label>
<input
id={taskBudgetId}
@@ -744,7 +878,7 @@ export function ConfigTab({ workspaceId }: Props) {
value={config.task_budget ?? 0}
onChange={(e) => update("task_budget", parseInt(e.target.value, 10) || 0)}
placeholder="0"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent font-mono"
data-testid="task-budget-input"
/>
</div>
@@ -775,12 +909,12 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}>
<div>
<label htmlFor={sandboxBackendId} className="text-[10px] text-zinc-500 block mb-1">Backend</label>
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-soft block mb-1">Backend</label>
<select
id={sandboxBackendId}
value={config.sandbox?.backend || "docker"}
onChange={(e) => updateNested("sandbox" as keyof ConfigData, "backend", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
>
<option value="subprocess">subprocess</option>
<option value="docker">docker</option>
@@ -803,25 +937,25 @@ export function ConfigTab({ workspaceId }: Props) {
)}
{error && (
<div className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">{error}</div>
<div className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
)}
{!error && RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "") && (
<div className="mx-3 mb-2 px-3 py-1.5 bg-zinc-900/50 border border-zinc-700 rounded text-xs text-zinc-400">
<div className="mx-3 mb-2 px-3 py-1.5 bg-surface-sunken/50 border border-line rounded text-xs text-ink-mid">
{config.runtime === "hermes"
? "Hermes manages its own config at ~/.hermes/config.yaml on the workspace host. Edit it via the Terminal tab or the hermes CLI, not this form."
: "This runtime manages its own config outside the platform template."}
</div>
)}
{success && (
<div className="mx-3 mb-2 px-3 py-1.5 bg-green-900/30 border border-green-800 rounded text-xs text-green-400">Saved</div>
<div className="mx-3 mb-2 px-3 py-1.5 bg-green-900/30 border border-green-800 rounded text-xs text-good">Saved</div>
)}
<div className="p-3 border-t border-zinc-800 flex gap-2">
<div className="p-3 border-t border-line flex gap-2">
<button
type="button"
onClick={() => handleSave(true)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-30 transition-colors"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-30 transition-colors"
>
{saving ? "Restarting..." : "Save & Restart"}
</button>
@@ -829,14 +963,14 @@ export function ConfigTab({ workspaceId }: Props) {
type="button"
onClick={() => handleSave(false)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 disabled:opacity-30 transition-colors"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors"
>
Save
</button>
<button
type="button"
onClick={loadConfig}
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300 ml-auto"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid ml-auto"
>
Reload
</button>
+27 -27
View File
@@ -133,7 +133,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-sm text-ink focus:outline-none focus:border-accent"
/>
</Field>
<Field label="Role">
@@ -141,14 +141,14 @@ export function DetailsTab({ workspaceId, data }: Props) {
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="e.g. SEO Specialist"
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-sm text-ink focus:outline-none focus:border-accent"
/>
</Field>
<Field label="Tier">
<select
value={tier}
onChange={(e) => setTier(Number(e.target.value))}
className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-sm text-ink focus:outline-none focus:border-accent"
>
<option value={1}>Tier 1 No privileges</option>
<option value={2}>Tier 2 Browser</option>
@@ -157,7 +157,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
</select>
</Field>
{saveError && (
<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">
{saveError}
</div>
)}
@@ -166,7 +166,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
type="button"
onClick={handleSave}
disabled={saving}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white disabled:opacity-50"
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
@@ -179,7 +179,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
setRole(data.role || "");
setTier(data.tier);
}}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -200,7 +200,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{isRestartable && (
<div className="pt-2">
{restartError && (
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{restartError}
</div>
)}
@@ -217,7 +217,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={() => setEditing(true)}
className="mt-2 px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Edit
</button>
@@ -234,17 +234,17 @@ export function DetailsTab({ workspaceId, data }: Props) {
{data.lastSampleError ? (
<pre
data-testid="details-error-log"
className="text-[11px] text-red-300 font-mono whitespace-pre-wrap break-all bg-red-950/20 border border-red-900/40 rounded p-2 max-h-[240px] overflow-auto leading-tight"
className="text-[11px] text-bad font-mono whitespace-pre-wrap break-all bg-red-950/20 border border-red-900/40 rounded p-2 max-h-[240px] overflow-auto leading-tight"
>
{data.lastSampleError}
</pre>
) : (
<p className="text-xs text-zinc-500">No error detail recorded.</p>
<p className="text-xs text-ink-soft">No error detail recorded.</p>
)}
<button
type="button"
onClick={() => setConsoleOpen(true)}
className="mt-2 px-3 py-1 bg-zinc-800 hover:bg-zinc-700 text-xs rounded text-zinc-300 border border-zinc-700"
className="mt-2 px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid border border-line"
>
View console output
</button>
@@ -263,9 +263,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
<div className="space-y-1">
{skills.map((s) => (
<div key={s.id} className="flex items-start gap-2">
<span className="text-xs text-blue-400 font-mono shrink-0">{s.id}</span>
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
{s.description && (
<span className="text-xs text-zinc-500">{s.description}</span>
<span className="text-xs text-ink-soft">{s.description}</span>
)}
</div>
))}
@@ -276,13 +276,13 @@ export function DetailsTab({ workspaceId, data }: Props) {
{/* Peers */}
<Section title={`Peers (${peers.length})`}>
{peersError ? (
<p className="text-xs text-red-400">{peersError}</p>
<p className="text-xs text-bad">{peersError}</p>
) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? (
<p className="text-xs text-zinc-500">
<p className="text-xs text-ink-soft">
Peers are only discoverable while the workspace is online.
</p>
) : peers.length === 0 ? (
<p className="text-xs text-zinc-500">No reachable peers</p>
<p className="text-xs text-ink-soft">No reachable peers</p>
) : (
<div className="space-y-1">
{peers.map((p) => (
@@ -290,11 +290,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
key={p.id}
type="button"
onClick={() => selectNode(p.id)}
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-zinc-800 text-left"
className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-surface-card text-left"
>
<StatusDot status={p.status} />
<span className="text-xs text-zinc-200">{p.name}</span>
{p.role && <span className="text-[10px] text-zinc-500">{p.role}</span>}
<span className="text-xs text-ink">{p.name}</span>
{p.role && <span className="text-[10px] text-ink-soft">{p.role}</span>}
</button>
))}
</div>
@@ -304,7 +304,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{/* Delete */}
<Section title="Danger Zone">
{deleteError && (
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{deleteError}
</div>
)}
@@ -315,7 +315,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
aria-labelledby="delete-confirm-title"
className="space-y-2"
>
<h3 id="delete-confirm-title" className="text-xs font-medium text-red-400">
<h3 id="delete-confirm-title" className="text-xs font-medium text-bad">
Confirm deletion
</h3>
<div className="flex gap-2">
@@ -334,7 +334,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
// Return focus to the trigger so keyboard users aren't stranded
deleteButtonRef.current?.focus();
}}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -345,7 +345,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
type="button"
ref={deleteButtonRef}
onClick={() => setConfirmDelete(true)}
className="px-3 py-1 bg-zinc-800 hover:bg-red-900 border border-zinc-700 hover:border-red-700 text-xs rounded text-zinc-400 hover:text-red-400 transition-colors"
className="px-3 py-1 bg-surface-card hover:bg-red-900 border border-line hover:border-red-700 text-xs rounded text-ink-mid hover:text-bad transition-colors"
>
Delete Workspace
</button>
@@ -367,7 +367,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2">{title}</h3>
<h3 className="text-xs font-semibold text-ink-mid uppercase tracking-wider mb-2">{title}</h3>
{children}
</div>
);
@@ -377,7 +377,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
const fieldId = useId();
return (
<div>
<label htmlFor={fieldId} className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
<label htmlFor={fieldId} className="text-[10px] text-ink-soft block mb-0.5">{label}</label>
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
</div>
);
@@ -386,8 +386,8 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between">
<span className="text-xs text-zinc-500">{label}</span>
<span className={`text-xs text-zinc-200 ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
<span className="text-xs text-ink-soft">{label}</span>
<span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
{value}
</span>
</div>
+16 -16
View File
@@ -16,12 +16,12 @@ interface EventEntry {
}
const EVENT_COLORS: Record<string, string> = {
WORKSPACE_ONLINE: "text-green-400",
WORKSPACE_OFFLINE: "text-zinc-400",
WORKSPACE_ONLINE: "text-good",
WORKSPACE_OFFLINE: "text-ink-mid",
WORKSPACE_DEGRADED: "text-yellow-400",
WORKSPACE_PROVISIONING: "text-blue-400",
WORKSPACE_REMOVED: "text-red-400",
WORKSPACE_PROVISION_FAILED: "text-red-400",
WORKSPACE_PROVISIONING: "text-accent",
WORKSPACE_REMOVED: "text-bad",
WORKSPACE_PROVISION_FAILED: "text-bad",
AGENT_CARD_UPDATED: "text-purple-400",
};
@@ -56,58 +56,58 @@ export function EventsTab({ workspaceId }: Props) {
}, [loadEvents]);
if (loading && events.length === 0) {
return <div className="p-4 text-xs text-zinc-500">Loading events...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading events...</div>;
}
return (
<div className="p-4 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-zinc-400">{events.length} events</span>
<span className="text-xs text-ink-mid">{events.length} events</span>
<button
onClick={loadEvents}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
>
Refresh
</button>
</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>
)}
{!error && events.length === 0 ? (
<p className="text-xs text-zinc-500 text-center py-4">No events yet</p>
<p className="text-xs text-ink-soft text-center py-4">No events yet</p>
) : (
<div className="space-y-1">
{events.map((event) => (
<div key={event.id} className="bg-zinc-800 rounded border border-zinc-700">
<div key={event.id} className="bg-surface-card rounded border border-line">
<button
onClick={() => setExpanded(expanded === event.id ? null : event.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-left"
>
<span
className={`text-xs font-mono ${
EVENT_COLORS[event.event_type] || "text-zinc-300"
EVENT_COLORS[event.event_type] || "text-ink-mid"
}`}
>
{event.event_type}
</span>
<span className="text-[9px] text-zinc-500 ml-auto">
<span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)}
</span>
<span className="text-[10px] text-zinc-500">
<span className="text-[10px] text-ink-soft">
{expanded === event.id ? "▼" : "▶"}
</span>
</button>
{expanded === event.id && (
<div className="px-3 pb-2">
<pre className="text-[10px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-40">
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(event.payload, null, 2)}
</pre>
<div className="mt-1 text-[9px] text-zinc-500 font-mono">
<div className="mt-1 text-[9px] text-ink-soft font-mono">
ID: {event.id}
</div>
</div>
+10 -10
View File
@@ -145,7 +145,7 @@ export function FilesTab({ workspaceId }: Props) {
};
if (loading) {
return <div className="p-4 text-xs text-zinc-500">Loading files...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading files...</div>;
}
return (
@@ -163,34 +163,34 @@ export function FilesTab({ workspaceId }: Props) {
{showDeleteAll && (
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p className="text-xs text-red-300">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<p className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
<div className="flex gap-2">
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
</div>
</div>
)}
{error && (
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">{error}</div>
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
)}
{confirmDelete && (
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p className="text-xs text-amber-300">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<p className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div className="flex gap-2">
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300">Cancel</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
</div>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* File tree */}
<div className="w-[180px] border-r border-zinc-800/40 overflow-y-auto shrink-0">
<div className="w-[180px] border-r border-line/40 overflow-y-auto shrink-0">
{/* New file input */}
{showNewFile && (
<div className="px-2 py-1 border-b border-zinc-800/40">
<div className="px-2 py-1 border-b border-line/40">
<input
aria-label="New file path"
value={newFileName}
@@ -198,13 +198,13 @@ export function FilesTab({ workspaceId }: Props) {
onKeyDown={(e) => e.key === "Enter" && createFile()}
placeholder="path/file.md"
autoFocus
className="w-full bg-zinc-800 border border-zinc-600 rounded px-1.5 py-0.5 text-[10px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500"
className="w-full bg-surface-card border border-line rounded px-1.5 py-0.5 text-[10px] text-ink font-mono focus:outline-none focus:border-accent"
/>
</div>
)}
{files.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-zinc-600 text-center">
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
No config files yet
</div>
) : (
@@ -36,7 +36,7 @@ export function FileEditor({
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-zinc-600">Select a file to edit</p>
<p className="text-[10px] text-ink-soft">Select a file to edit</p>
</div>
</div>
);
@@ -45,18 +45,18 @@ export function FileEditor({
return (
<>
{/* File header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800/40 bg-zinc-900/20">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/20">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] font-mono text-zinc-300 truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-amber-400">modified</span>}
<span className="text-[10px] font-mono text-ink-mid truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-warm">modified</span>}
</div>
<div className="flex items-center gap-2">
{success && <span className="text-[9px] text-emerald-400">{success}</span>}
{success && <span className="text-[9px] text-good">{success}</span>}
<button
onClick={onDownload}
aria-label="Download file"
className="text-[10px] text-zinc-500 hover:text-zinc-300"
className="text-[10px] text-ink-soft hover:text-ink-mid"
>
</button>
@@ -64,7 +64,7 @@ export function FileEditor({
<button
onClick={onSave}
disabled={!isDirty || saving}
className="text-[10px] text-blue-400 hover:text-blue-300 disabled:opacity-30"
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
>
{saving ? "Saving..." : "Save"}
</button>
@@ -74,7 +74,7 @@ export function FileEditor({
{/* Editor area */}
{loadingFile ? (
<div className="p-4 text-xs text-zinc-500">Loading...</div>
<div className="p-4 text-xs text-ink-soft">Loading...</div>
) : (
<textarea
ref={editorRef}
@@ -103,7 +103,7 @@ export function FileEditor({
}
}}
spellCheck={false}
className="flex-1 w-full bg-zinc-950 p-3 text-[11px] font-mono text-zinc-200 leading-relaxed resize-none focus:outline-none"
className="flex-1 w-full bg-surface p-3 text-[11px] font-mono text-ink leading-relaxed resize-none focus:outline-none"
style={{ tabSize: 2 }}
/>
)}
@@ -58,20 +58,20 @@ function TreeItem({
return (
<div>
<div
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-zinc-800/40 transition-colors cursor-pointer"
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
>
<span className="text-[9px] text-zinc-500 w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
<span className="text-[10px] text-zinc-400 flex-1">{node.name}</span>
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
<button
aria-label={`Delete ${node.name}`}
onClick={(e) => {
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
>
</button>
@@ -95,7 +95,7 @@ function TreeItem({
return (
<div
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
isSelected ? "bg-blue-900/30 text-zinc-100" : "hover:bg-zinc-800/40 text-zinc-400"
isSelected ? "bg-blue-900/30 text-ink" : "hover:bg-surface-card/40 text-ink-mid"
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
@@ -108,7 +108,7 @@ function TreeItem({
e.stopPropagation();
onDelete(node.path);
}}
className="text-[9px] text-red-400/0 group-hover:text-red-400/60 hover:!text-red-400 transition-colors"
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
>
</button>
@@ -26,25 +26,25 @@ export function FilesToolbar({
const uploadRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/40 bg-zinc-900/30">
<div className="flex items-center justify-between px-3 py-2 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center gap-2">
<select
value={root}
onChange={(e) => setRoot(e.target.value)}
aria-label="File root directory"
className="text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 rounded px-1.5 py-0.5 outline-none"
className="text-[10px] bg-surface-card text-ink-mid border border-line rounded px-1.5 py-0.5 outline-none"
>
<option value="/configs">/configs</option>
<option value="/home">/home</option>
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
</select>
<span className="text-[10px] text-zinc-500">{fileCount} files</span>
<span className="text-[10px] text-ink-soft">{fileCount} files</span>
</div>
<div className="flex gap-1.5">
{root === "/configs" && (
<>
<button type="button" onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
<button type="button" onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-accent hover:text-accent" title="Create new file">
+ New
</button>
<input
@@ -57,20 +57,20 @@ export function FilesToolbar({
className="hidden"
onChange={(e) => e.target.files && onUpload(e.target.files)}
/>
<button type="button" onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
<button type="button" onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-accent hover:text-accent" title="Upload folder">
Upload
</button>
</>
)}
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Download all files">
Export
</button>
{root === "/configs" && (
<button type="button" onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
<button type="button" onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-bad/60 hover:text-bad" title="Delete all files">
Clear
</button>
)}
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Refresh">
</button>
</div>
+47 -47
View File
@@ -114,13 +114,13 @@ export function MemoryTab({ workspaceId }: Props) {
};
if (loading) {
return <div className="p-4 text-xs text-zinc-500">Loading memory...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading memory...</div>;
}
return (
<div className="p-4 space-y-4">
{error && !showAdd && (
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{error}
</div>
)}
@@ -128,8 +128,8 @@ export function MemoryTab({ workspaceId }: Props) {
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-zinc-200">Awareness dashboard</div>
<p className="text-[10px] text-zinc-500">
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
<p className="text-[10px] text-ink-soft">
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
</p>
</div>
@@ -137,14 +137,14 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-200"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
>
Open
</button>
@@ -153,7 +153,7 @@ export function MemoryTab({ workspaceId }: Props) {
{showAwareness ? (
AWARENESS_BASE_URL ? (
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
<iframe
title="Awareness dashboard"
src={awarenessUrl}
@@ -162,71 +162,71 @@ export function MemoryTab({ workspaceId }: Props) {
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
Set <code className="font-mono text-zinc-300">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-soft">
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
</div>
)
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-zinc-200">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-zinc-500 truncate">
Workspace context stays linked through <span className="font-mono text-zinc-400">{workspaceId}</span>.
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-ink-soft truncate">
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
>
Expand
</button>
</div>
)}
<div className="grid gap-2 rounded-xl border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-[10px] text-zinc-400 sm:grid-cols-3">
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-zinc-500">Status</span>
<span className="font-medium text-emerald-300">Connected</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Status</span>
<span className="font-medium text-good">Connected</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-zinc-500">Mode</span>
<span className="font-medium text-zinc-200">{awarenessStatus}</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Mode</span>
<span className="font-medium text-ink">{awarenessStatus}</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="uppercase tracking-[0.18em] text-zinc-500">Workspace</span>
<span className="font-mono text-zinc-300 truncate">{workspaceId}</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Workspace</span>
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
</div>
</div>
</section>
<section className="space-y-3 border-t border-zinc-800/60 pt-4">
<section className="space-y-3 border-t border-line/60 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-zinc-200">Workspace KV memory</div>
<p className="text-[10px] text-zinc-500">
Native platform key-value memory for workspace <span className="font-mono text-zinc-400">{workspaceId}</span>.
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
<p className="text-[10px] text-ink-soft">
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[10px] rounded text-zinc-300"
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
>
+ Add
</button>
@@ -234,13 +234,13 @@ export function MemoryTab({ workspaceId }: Props) {
</div>
{showAdvanced && showAdd && (
<div className="bg-zinc-800 rounded p-3 space-y-2 border border-zinc-700">
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
<input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
aria-label="Memory key"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
<textarea
value={newValue}
@@ -248,21 +248,21 @@ export function MemoryTab({ workspaceId }: Props) {
placeholder='Value (JSON or plain text)'
rows={3}
aria-label="Memory value (JSON or plain text)"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs font-mono text-zinc-100 focus:outline-none focus:border-blue-500 resize-none"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={newTTL}
onChange={(e) => setNewTTL(e.target.value)}
placeholder="TTL in seconds (optional)"
aria-label="TTL in seconds (optional)"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{error && <div role="alert" className="text-xs text-red-400">{error}</div>}
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-xs rounded text-white"
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white"
>
Save
</button>
@@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
setShowAdd(false);
setError(null);
}}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -282,25 +282,25 @@ export function MemoryTab({ workspaceId }: Props) {
{showAdvanced ? (
entries.length === 0 ? (
<p className="text-xs text-zinc-500 text-center py-4">No memory entries</p>
<p className="text-xs text-ink-soft text-center py-4">No memory entries</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div key={entry.key} className="bg-zinc-800 rounded border border-zinc-700">
<div key={entry.key} className="bg-surface-card rounded border border-line">
<button
type="button"
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left"
aria-expanded={expanded === entry.key}
>
<span className="text-xs font-mono text-blue-400">{entry.key}</span>
<span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2">
{entry.expires_at && (
<span className="text-[9px] text-zinc-500">
<span className="text-[9px] text-ink-soft">
TTL {new Date(entry.expires_at).toLocaleString()}
</span>
)}
<span className="text-[10px] text-zinc-500">
<span className="text-[10px] text-ink-soft">
{expanded === entry.key ? "▼" : "▶"}
</span>
</div>
@@ -308,17 +308,17 @@ export function MemoryTab({ workspaceId }: Props) {
{expanded === entry.key && (
<div className="px-3 pb-2 space-y-2">
<pre className="text-[10px] text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto max-h-40">
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(entry.value, null, 2)}
</pre>
<div className="flex items-center justify-between">
<span className="text-[9px] text-zinc-500">
<span className="text-[9px] text-ink-soft">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-red-400 hover:text-red-300"
className="text-[10px] text-bad hover:text-bad"
>
Delete
</button>
@@ -330,17 +330,17 @@ export function MemoryTab({ workspaceId }: Props) {
</div>
)
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-950/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-zinc-200">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-zinc-500 truncate">
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-ink-soft truncate">
KV entries remain available if you need the raw platform store.
</p>
</div>
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-blue-600 hover:bg-blue-500 text-[10px] rounded text-white"
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
>
Show
</button>
+32 -32
View File
@@ -180,19 +180,19 @@ export function ScheduleTab({ workspaceId }: Props) {
};
if (loading) {
return <div className="p-4 text-[10px] text-zinc-500">Loading schedules...</div>;
return <div className="p-4 text-[10px] text-ink-soft">Loading schedules...</div>;
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/50">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
<div className="flex items-center justify-between px-3 py-2 border-b border-line/50">
<span className="text-[10px] font-semibold text-ink-mid uppercase tracking-wider">
Schedules
</span>
<button
onClick={() => { resetForm(); setShowForm(true); }}
className="text-[11px] px-2 py-0.5 bg-blue-600/20 text-blue-400 rounded hover:bg-blue-600/30 transition-colors"
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
>
+ Add Schedule
</button>
@@ -200,36 +200,36 @@ export function ScheduleTab({ workspaceId }: Props) {
{/* Create/Edit Form */}
{showForm && (
<div className="p-3 border-b border-zinc-800/50 bg-zinc-900/50 space-y-2">
<div className="p-3 border-b border-line/50 bg-surface-sunken/50 space-y-2">
<input
type="text"
aria-label="Schedule name"
placeholder="Schedule name (e.g., Daily security scan)"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 placeholder:text-zinc-600"
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft"
/>
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor={cronId} className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
<label htmlFor={cronId} className="text-[10px] text-ink-soft block mb-0.5">Cron Expression</label>
<input
id={cronId}
type="text"
value={formCron}
onChange={(e) => setFormCron(e.target.value)}
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 font-mono"
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
/>
<div className="text-[10px] text-zinc-600 mt-0.5">
<div className="text-[10px] text-ink-soft mt-0.5">
{cronToHuman(formCron)}
</div>
</div>
<div className="w-24">
<label htmlFor={timezoneId} className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
<label htmlFor={timezoneId} className="text-[10px] text-ink-soft block mb-0.5">Timezone</label>
<select
id={timezoneId}
value={formTimezone}
onChange={(e) => setFormTimezone(e.target.value)}
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-1 py-1 text-zinc-200"
className="w-full text-[10px] bg-surface-card border border-line rounded px-1 py-1 text-ink"
>
<option value="UTC">UTC</option>
<option value="America/New_York">US Eastern</option>
@@ -245,44 +245,44 @@ export function ScheduleTab({ workspaceId }: Props) {
</div>
</div>
<div>
<label htmlFor={promptId} className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
<label htmlFor={promptId} className="text-[10px] text-ink-soft block mb-0.5">Prompt / Task</label>
<textarea
id={promptId}
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder="What should the agent do on this schedule?"
rows={3}
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-200 placeholder:text-zinc-600 resize-y"
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft resize-y"
/>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400 cursor-pointer">
<label className="flex items-center gap-1.5 text-[10px] text-ink-mid cursor-pointer">
<input
type="checkbox"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
className="rounded border-zinc-600"
className="rounded border-line"
/>
Enabled
</label>
</div>
{error && <div className="text-[10px] text-red-400">{error}</div>}
{error && <div className="text-[10px] text-bad">{error}</div>}
<div className="flex gap-2">
<button
onClick={handleSubmit}
disabled={!formCron || !formPrompt}
className="text-[11px] px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-40 transition-colors"
className="text-[11px] px-3 py-1 bg-accent-strong text-white rounded hover:bg-accent disabled:opacity-40 transition-colors"
>
{editId ? "Update" : "Create"}
</button>
<button
onClick={resetForm}
className="text-[11px] px-3 py-1 bg-zinc-800 text-zinc-400 rounded hover:bg-zinc-700 transition-colors"
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-card transition-colors"
>
Cancel
</button>
</div>
<div className="text-[10px] text-zinc-600 space-y-0.5">
<div className="text-[10px] text-ink-soft space-y-0.5">
<div>Common patterns:</div>
<div className="font-mono">{"0 9 * * *"} Daily at 9:00 AM</div>
<div className="font-mono">{"*/30 * * * *"} Every 30 minutes</div>
@@ -297,8 +297,8 @@ export function ScheduleTab({ workspaceId }: Props) {
{schedules.length === 0 && !showForm ? (
<div className="p-6 text-center">
<div className="text-2xl mb-2"></div>
<div className="text-[10px] text-zinc-400 mb-1">No schedules yet</div>
<div className="text-[9px] text-zinc-500">
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
<div className="text-[9px] text-ink-soft">
Add a schedule to run tasks automatically daily scans, periodic reports, standup reminders.
</div>
</div>
@@ -306,7 +306,7 @@ export function ScheduleTab({ workspaceId }: Props) {
schedules.map((sched) => (
<div
key={sched.id}
className={`px-3 py-2 border-b border-zinc-800/30 ${
className={`px-3 py-2 border-b border-line/30 ${
!sched.enabled ? "opacity-50" : ""
}`}
>
@@ -320,30 +320,30 @@ export function ScheduleTab({ workspaceId }: Props) {
? "bg-red-400"
: sched.last_status === "ok"
? "bg-emerald-400"
: "bg-zinc-600"
: "bg-surface-card"
}`}
title={sched.enabled ? "Click to disable" : "Click to enable"}
/>
<span className="text-[10px] font-medium text-zinc-200 truncate">
<span className="text-[10px] font-medium text-ink truncate">
{sched.name || "Unnamed schedule"}
</span>
</div>
<div className="text-[9px] text-zinc-500 mt-0.5 font-mono">
<div className="text-[9px] text-ink-soft mt-0.5 font-mono">
{cronToHuman(sched.cron_expr)}
{sched.timezone !== "UTC" && (
<span className="text-zinc-600"> ({sched.timezone})</span>
<span className="text-ink-soft"> ({sched.timezone})</span>
)}
</div>
<div className="text-[9px] text-zinc-500 mt-0.5 truncate">
<div className="text-[9px] text-ink-soft mt-0.5 truncate">
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
</div>
<div className="flex items-center gap-3 mt-1 text-[8px] text-zinc-500">
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-soft">
<span>Last: {relativeTime(sched.last_run_at)}</span>
<span>Next: {relativeTime(sched.next_run_at)}</span>
<span>Runs: {sched.run_count}</span>
</div>
{sched.last_error && (
<div className="text-[8px] text-red-400/70 mt-0.5 truncate">
<div className="text-[8px] text-bad/70 mt-0.5 truncate">
Error: {sched.last_error}
</div>
)}
@@ -352,7 +352,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleRunNow(sched)}
aria-label={`Run schedule ${sched.name} now`}
className="text-[11px] px-1.5 py-0.5 text-blue-400 hover:bg-blue-600/20 rounded transition-colors"
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
title="Run now"
>
@@ -360,7 +360,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleEdit(sched)}
aria-label={`Edit schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-zinc-400 hover:bg-zinc-700 rounded transition-colors"
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
title="Edit"
>
@@ -368,7 +368,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
aria-label={`Delete schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-red-400 hover:bg-red-600/20 rounded transition-colors"
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
title="Delete"
>
+50 -50
View File
@@ -300,11 +300,11 @@ export function SkillsTab({ workspaceId, data }: Props) {
return (
<div className="p-4 space-y-4">
{/* Plugins section */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/70 p-3">
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">Plugins</div>
<h3 className="mt-1 text-sm font-semibold text-zinc-100">
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
<h3 className="mt-1 text-sm font-semibold text-ink">
{installed.length} installed
</h3>
</div>
@@ -332,27 +332,27 @@ export function SkillsTab({ workspaceId, data }: Props) {
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 ${
inert
? "border-amber-800/40 bg-amber-950/10 opacity-70"
: "border-zinc-800/60 bg-zinc-950/40"
: "border-line/60 bg-surface/40"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-zinc-200">{p.name}</span>
{p.version && <span className="text-[10px] text-zinc-600">v{p.version}</span>}
<span className="text-[11px] font-medium text-ink">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
{inert && (
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-amber-300">
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm">
inert on this runtime
</span>
)}
</div>
{p.description && <div className="text-[10px] text-zinc-500 truncate">{p.description}</div>}
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.skills && p.skills.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.skills.slice(0, 4).map((s) => (
<span key={s} className="rounded-full bg-zinc-800/60 px-1.5 py-0.5 text-[10px] text-zinc-400">{s}</span>
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span>
))}
{p.skills.length > 4 && (
<span className="text-[10px] text-zinc-600">+{p.skills.length - 4}</span>
<span className="text-[10px] text-ink-soft">+{p.skills.length - 4}</span>
)}
</div>
)}
@@ -360,7 +360,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<button
onClick={() => handleUninstall(p.name)}
disabled={uninstalling === p.name}
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-red-400 hover:bg-red-900/30 disabled:opacity-30"
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30"
>
{uninstalling === p.name ? "..." : "Remove"}
</button>
@@ -372,11 +372,11 @@ export function SkillsTab({ workspaceId, data }: Props) {
{/* Plugin registry (expandable) */}
{showRegistry && (
<div className="mt-3 border-t border-zinc-800/40 pt-3">
<div className="mt-3 border-t border-line/40 pt-3">
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-zinc-800/60 bg-zinc-950/40 p-2.5">
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-600">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">
Install from source
</div>
{sourceSchemes.length > 0 && (
@@ -384,7 +384,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-zinc-700/50 bg-zinc-900/50 px-1.5 py-0.5 text-[10px] text-zinc-500"
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-soft"
>
{s}://
</span>
@@ -403,7 +403,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
}}
placeholder="e.g. github://owner/repo#v1.0"
spellCheck={false}
className="flex-1 rounded border border-zinc-700 bg-zinc-950 px-2 py-1 text-[10px] text-zinc-200 placeholder:text-zinc-600 focus:border-violet-600 focus:outline-none"
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:border-violet-600 focus:outline-none"
/>
<button
onClick={handleInstallCustom}
@@ -413,12 +413,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
{installing === customSource.trim() ? "Installing..." : "Install"}
</button>
</div>
<div className="mt-1 text-[10px] text-zinc-600">
<div className="mt-1 text-[10px] text-ink-soft">
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-600">Available plugins</div>
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Available plugins</div>
{/* Retry visible whenever registry is empty — including
the loading state — so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a
@@ -445,21 +445,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
</div>
{registryLoading && registry.length === 0 ? (
<div className="text-[10px] text-zinc-500">Loading registry</div>
<div className="text-[10px] text-ink-soft">Loading registry</div>
) : registryError ? (
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-red-300 font-semibold mb-0.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-red-400/80">{registryError}</div>
<div className="mt-1 text-[10px] text-zinc-500">
<div className="text-[10px] text-bad/80">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-soft">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
</div>
) : registry.length === 0 ? (
<div className="rounded-lg border border-zinc-800/40 bg-zinc-950/40 px-2 py-1.5">
<div className="text-[10px] text-zinc-400 mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-zinc-600">
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-soft">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
@@ -469,30 +469,30 @@ export function SkillsTab({ workspaceId, data }: Props) {
{registry.map((p) => {
const isInstalled = installedNames.has(p.name);
return (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-lg border border-zinc-800/40 bg-zinc-950/30 px-3 py-2">
<div key={p.name} className="flex items-center justify-between gap-2 rounded-lg border border-line/40 bg-surface/30 px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] text-zinc-300">{p.name}</span>
{p.version && <span className="text-[10px] text-zinc-600">v{p.version}</span>}
<span className="text-[11px] text-ink-mid">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
</div>
{p.description && <div className="text-[10px] text-zinc-500 truncate">{p.description}</div>}
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.tags && p.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.tags.map((t) => (
<span key={t} className="rounded-full border border-zinc-700/40 px-1.5 py-0.5 text-[10px] text-zinc-500">{t}</span>
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-soft">{t}</span>
))}
</div>
)}
{p.runtimes && p.runtimes.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.runtimes.map((r) => (
<span key={r} className="rounded-full border border-blue-800/40 bg-blue-950/20 px-1.5 py-0.5 text-[10px] text-blue-300">{r}</span>
<span key={r} className="rounded-full border border-blue-800/40 bg-blue-950/20 px-1.5 py-0.5 text-[10px] text-accent">{r}</span>
))}
</div>
)}
</div>
{isInstalled ? (
<span className="shrink-0 text-[10px] text-emerald-500">Installed</span>
<span className="shrink-0 text-[10px] text-good">Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
@@ -512,30 +512,30 @@ export function SkillsTab({ workspaceId, data }: Props) {
</div>
{/* Skills section */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/70 p-3">
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">Workspace skills</div>
<h3 className="mt-1 text-sm font-semibold text-zinc-100">Installed skills</h3>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Workspace skills</div>
<h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3>
</div>
<div className="flex flex-wrap gap-2">
<MetaPill label="Count" value={String(capability.skillCount)} />
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
</div>
</div>
<p className="mt-2 text-[11px] leading-5 text-zinc-500">
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
Live skill directory from the Agent Card updates when the workspace hot-reloads skills.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
onClick={() => setPanelTab("config")}
className="rounded-full border border-zinc-700 bg-zinc-950 px-3 py-1 text-[10px] text-zinc-300 hover:bg-zinc-900"
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
>
Open Config
</button>
<button
onClick={() => setPanelTab("files")}
className="rounded-full border border-zinc-700 bg-zinc-950 px-3 py-1 text-[10px] text-zinc-300 hover:bg-zinc-900"
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
>
Open Files
</button>
@@ -550,27 +550,27 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
{skills.length === 0 ? (
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/40 p-6 text-center">
<div className="text-sm text-zinc-100">No skills loaded</div>
<p className="mt-2 text-[11px] leading-5 text-zinc-500">
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center">
<div className="text-sm text-ink">No skills loaded</div>
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
</p>
</div>
) : (
<div className="space-y-2">
{skills.map((skill) => (
<div key={skill.id} className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-3">
<div key={skill.id} className="rounded-xl border border-line bg-surface-sunken/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-zinc-100">{skill.name}</div>
<div className="mt-0.5 text-[10px] font-mono text-zinc-500">{skill.id}</div>
<div className="text-xs font-semibold text-ink">{skill.name}</div>
<div className="mt-0.5 text-[10px] font-mono text-ink-soft">{skill.id}</div>
</div>
{skill.tags.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5">
{skill.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[9px] text-zinc-400"
className="rounded-full border border-line bg-surface-sunken px-2 py-0.5 text-[9px] text-ink-mid"
>
{tag}
</span>
@@ -580,17 +580,17 @@ export function SkillsTab({ workspaceId, data }: Props) {
</div>
{skill.description && (
<p className="mt-2 text-[11px] leading-5 text-zinc-400">{skill.description}</p>
<p className="mt-2 text-[11px] leading-5 text-ink-mid">{skill.description}</p>
)}
{skill.examples.length > 0 && (
<div className="mt-2">
<div className="text-[9px] uppercase tracking-[0.2em] text-zinc-500">Examples</div>
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-soft">Examples</div>
<div className="mt-1 space-y-1">
{skill.examples.slice(0, 2).map((example, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-2 py-1 text-[10px] text-zinc-300"
className="rounded-md border border-line bg-surface/60 px-2 py-1 text-[10px] text-ink-mid"
>
{example}
</div>
@@ -624,8 +624,8 @@ function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[]
function MetaPill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-zinc-700/60 bg-zinc-950/60 px-2 py-1 text-[9px] text-zinc-300">
<span className="uppercase tracking-[0.18em] text-[8px] text-zinc-500">{label}</span>
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-soft">{label}</span>
<span className="font-medium">{value}</span>
</span>
);

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