Compare commits

..

134 Commits

Author SHA1 Message Date
molecule-ai[bot] 5978cb3c45 Merge pull request #2703 from Molecule-AI/staging
staging → main: auto-promote 2e3e36b
2026-05-04 06:33:00 +00:00
Hongming Wang 3934325e23 Merge pull request #2702 from Molecule-AI/auto-sync/main-63d9158e
chore: sync main → staging (auto, ff to 63d9158e)
2026-05-04 06:22:02 +00:00
hongming 2e3e36b91f Merge pull request #2701 from Molecule-AI/feat/universal-mcp-content-reply-hint
feat(mcp): wrap inbound channel content with identity + reply hint
2026-05-04 06:16:57 +00:00
molecule-ai[bot] 63d9158e12 Merge pull request #2700 from Molecule-AI/staging
staging → main: auto-promote 2678998
2026-05-04 06:15:39 +00:00
Hongming Wang b7c962bf86 feat(mcp): wrap inbound channel content with identity + reply hint
Mirrors the channel-plugin change in
Molecule-AI/molecule-mcp-claude-channel#24 so the universal MCP path
(in-workspace agents) gets the same self-documenting reply guidance the
external channel plugin path now ships.

Before: `params.content` was the raw inbound text — Claude saw bare prose
from a peer or canvas user with no surrounding context. To reply the
agent had to (a) fish the routing fields out of `meta`, (b) recall which
platform tool routes to which destination (send_message_to_user for
canvas, delegate_task for peer), and (c) construct the call by hand.

After: content is wrapped as

  [from <identity> · peer_id=<uuid>]    (or "[from canvas user]")
  <inbound text>
  ↩ Reply: <copy-pasteable tool call>

The identity comes from the existing registry-enrichment path (peer_name
+ peer_role from enrich_peer_metadata, with friendly fallbacks when the
registry lookup misses). Reply tool name lives in the same module as the
notification builder so the `feedback_doc_tool_alignment` drift class
can't bite — a future tool rename PR that misses this hint also fails
test_format_channel_content_*.

Tests: 6 new cases pinning the formatter (canvas_user vs peer_agent,
full enrichment, name-only, no enrichment, unknown-kind defensive
default, multi-line preservation) plus updated existing assertions in
the bridge + content tests. All asserts pin exact strings per
`feedback_assert_exact_not_substring`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:14:12 -07:00
Hongming Wang 26789988df Merge pull request #2699 from Molecule-AI/a11y/canvas-create-workspace-dialog
canvas/CreateWorkspaceDialog: hover sweep + semantic placeholders + focus rings
2026-05-04 05:59:06 +00:00
Hongming Wang b6ff280ca3 canvas/CreateWorkspaceDialog: hover sweep + semantic placeholders + focus rings
Sweep on the workspace-creation dialog — same patterns shipped on every
other surface.

- 2× bg-accent-strong hover:bg-accent (FAB + Create) hovered LIGHTER
  on white text → bg-accent hover:bg-accent-strong + focus-visible
  rings.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
  elevated + focus-visible ring.
- 4× placeholder-zinc-500/600 hardcoded → placeholder-ink-soft so
  placeholders flip with theme.
- FAB shadow tinting (shadow-blue-600/20 + shadow-blue-500/30) was
  hardcoded blue with no theme variant; switched to shadow-accent so
  the glow tint matches the brand mint accent in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:56:33 -07:00
Hongming Wang acc10ca467 Merge pull request #2698 from Molecule-AI/auto-sync/main-f071cbb0
chore: sync main → staging (auto, ff to f071cbb0)
2026-05-04 05:53:20 +00:00
molecule-ai[bot] f071cbb0a3 Merge pull request #2697 from Molecule-AI/staging
staging → main: auto-promote 89e1096
2026-05-03 22:48:24 -07:00
Hongming Wang 3c70ddea5c Merge pull request #2695 from Molecule-AI/auto-sync/main-da59b8c5
chore: sync main → staging (auto, ff to da59b8c5)
2026-05-04 05:33:36 +00:00
Hongming Wang 89e10962b9 Merge pull request #2696 from Molecule-AI/a11y/canvas-org-import-skills
canvas/{OrgImportPreflightModal,SkillsTab}: 4 hover bugs + custom-source focus ring
2026-05-04 05:31:07 +00:00
Hongming Wang ff20fe4f61 canvas/{OrgImportPreflightModal,SkillsTab}: hover sweep + custom-source focus ring
OrgImportPreflightModal:
- 3× bg-accent-strong hover:bg-accent (Import + 2 add-key buttons) —
  accent is the LIGHTER variant, drops below AA on white text →
  bg-accent hover:bg-accent-strong.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
  elevated + focus-visible ring.

SkillsTab:
- Custom-source input had focus:border-violet-600 but no
  focus-visible ring — keyboard users only got a 1px border swap.
  Added focus-visible:ring-violet-600/50 (kept the violet to match
  the surrounding "custom install" UI's brand).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:28:41 -07:00
molecule-ai[bot] da59b8c5bc Merge pull request #2694 from Molecule-AI/staging
staging → main: auto-promote e307334
2026-05-04 05:21:13 +00:00
Hongming Wang e307334ca4 Merge pull request #2693 from Molecule-AI/a11y/canvas-tabs-button-sweep
canvas/{Details,Config,Activity}Tab: button hover sweep (6 buttons across 3 tabs)
2026-05-04 05:03:42 +00:00
Hongming Wang 0945936eee Merge pull request #2692 from Molecule-AI/auto-sync/main-25979072
chore: sync main → staging (auto, ff to 25979072)
2026-05-04 05:03:33 +00:00
Hongming Wang 16ad941a1e canvas/{Details,Config,Activity}Tab: button hover sweep across 6 buttons
Six button fixes — same trap patterns shipped on every other tab:

DetailsTab:
- Save button: bg-accent-strong hover:bg-accent (LIGHTER on white text,
  AA drop) → bg-accent hover:bg-accent-strong + focus-visible ring.
- Confirm Delete: bg-red-600 hover:bg-red-500 (LIGHTER on white text,
  AA drop) → bg-red-700 + focus-visible danger ring.
- Cancel: bg-surface-card hover:bg-surface-card (no-op) →
  surface-elevated.

ConfigTab:
- 2× Save buttons: same accent-LIGHTER trap → flipped + focus rings.
- Cancel: same no-op → surface-elevated.

ActivityTab:
- Refresh: same no-op → surface-elevated + focus-visible ring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:01:18 -07:00
molecule-ai[bot] 25979072fd Merge pull request #2691 from Molecule-AI/staging
staging → main: auto-promote 9973897
2026-05-04 04:54:21 +00:00
Hongming Wang 99738975e2 Merge pull request #2689 from Molecule-AI/auto-sync/main-4f6678ae
chore: sync main → staging (auto, ff to 4f6678ae)
2026-05-04 04:36:51 +00:00
Hongming Wang 66de1f1471 Merge pull request #2690 from Molecule-AI/a11y/canvas-schedule-tab-hovers
canvas/{Schedule,Channels}Tab: fix accent-LIGHTER hover + Cancel no-op
2026-05-04 04:36:05 +00:00
Hongming Wang 0e3e2559af canvas/{Schedule,Channels}Tab: fix accent-LIGHTER hover + Cancel no-op
Three button fixes — same AA-contrast-trap pattern shipped on
OnboardingWizard, MemoryTab, ConfirmDialog, ApprovalBanner.

ScheduleTab:
- Create/Update button: bg-accent-strong hover:bg-accent (accent is
  LIGHTER, drops below AA on white text) → bg-accent hover:bg-accent-
  strong + focus-visible ring.
- Cancel button: bg-surface-card hover:bg-surface-card no-op → hover
  surface-elevated + focus-visible ring.

ChannelsTab:
- Connect Channel button: same accent-LIGHTER trap → flipped + focus-
  visible ring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:33:23 -07:00
molecule-ai[bot] 4f6678ae52 Merge pull request #2688 from Molecule-AI/staging
staging → main: auto-promote 5de0eee
2026-05-03 21:30:22 -07:00
Hongming Wang 5de0eee328 Merge pull request #2687 from Molecule-AI/a11y/canvas-memory-tab-hovers
canvas/MemoryTab: fix 9 hover bugs (4 light + 4 no-op + Delete no-op)
2026-05-04 04:08:39 +00:00
Hongming Wang 40e35e0b6d Merge pull request #2686 from Molecule-AI/auto-sync/main-67e2c9c6
chore: sync main → staging (auto, ff to 67e2c9c6)
2026-05-04 04:07:34 +00:00
Hongming Wang 7a30af5af0 canvas/MemoryTab: fix 9 hover bugs (4 light + 4 no-op + Delete no-op)
Three matched fixes — same patterns shipped on OnboardingWizard,
ConfirmDialog, ApprovalBanner.

1. 4× bg-accent-strong hover:bg-accent (Save, Add, two Show buttons)
   hovered LIGHTER on white text — accent is the lighter variant, so
   contrast dropped below AA on hover. Flipped: bg-accent
   hover:bg-accent-strong.

2. 4× bg-surface-card hover:bg-surface-card no-op hovers (Collapse,
   Open, Hide-Advanced, Refresh, Cancel). Lift to surface-elevated
   so the buttons visibly respond.

3. Delete row button: text-bad hover:text-bad was a no-op. Switched
   to a light hover bg + focus-visible danger ring so the destructive
   action visibly responds and keyboard users see focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:06:17 -07:00
molecule-ai[bot] 67e2c9c6b3 Merge pull request #2685 from Molecule-AI/staging
staging → main: auto-promote a6ef5f9
2026-05-03 21:03:41 -07:00
Hongming Wang 43e0f69dc8 Merge pull request #2683 from Molecule-AI/auto-sync/main-5a50ba86
chore: sync main → staging (auto, ff to 5a50ba86)
2026-05-04 03:48:36 +00:00
Hongming Wang a6ef5f9583 Merge pull request #2684 from Molecule-AI/a11y/canvas-files-tab-confirms
canvas/FilesTab: fix Delete/Cancel hovers + alertdialog role + focus rings
2026-05-04 03:41:51 +00:00
Hongming Wang 38b1af3b84 canvas/FilesTab: fix Delete-LIGHTER + Cancel no-op + alertdialog role + focus rings
Three matched fixes for the inline Delete-All and Delete-File confirm
banners — same patterns shipped on ConfirmDialog/ApprovalBanner/
DeleteCascade:

1. Delete buttons hovered LIGHTER (bg-red-500 over bg-red-600). On
   white text drops below AA contrast. Flipped to bg-red-700.

2. Cancel buttons hover was a no-op (bg-surface-card on top of
   itself). Lift to surface-elevated, matching the Cancel pattern in
   ConfirmDialog.

3. None of the four buttons had focus-visible rings. Added danger
   ring on Delete, accent ring on Cancel, with ring-offset-surface
   so the offset color matches the inline banner backdrop.

4. Wrapped both confirm banners in role="alertdialog" + aria-
   labelledby pointing to the prompt text — SR users hear the
   destructive prompt immediately instead of as ambient text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:39:29 -07:00
molecule-ai[bot] 5a50ba86e8 Merge pull request #2682 from Molecule-AI/staging
staging → main: auto-promote 211e375
2026-05-04 03:36:04 +00:00
Hongming Wang 9fea10524e Merge pull request #2680 from Molecule-AI/auto-sync/main-dd5832a8
chore: sync main → staging (auto, ff to dd5832a8)
2026-05-04 03:18:35 +00:00
Hongming Wang 211e375ef1 Merge pull request #2681 from Molecule-AI/a11y/canvas-traces-tab
canvas/TracesTab: semantic status dots + aria-expanded on row expanders
2026-05-04 03:14:50 +00:00
Hongming Wang 38e0fc8ea0 canvas/TracesTab: semantic status dots + aria-expanded on row expanders
Three small UIUX fixes for the workspace Traces tab — same pattern
shipped on EventsTab.

1. Status dots were hardcoded bg-red-400 / bg-emerald-400 — semantic-
   token misses. Switched to bg-bad / bg-good so they pin to the
   canvas-wide ramp instead of Tailwind raw tones.

2. Trace expander rows had no aria-expanded — SR users heard a
   generic "button" with no toggle indication. Added aria-expanded
   + aria-controls pointing to the detail panel id.

3. Refresh + each expander button now carry focus-visible:ring-accent
   so keyboard users see where focus lands. Both were hover-only
   before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:12:21 -07:00
molecule-ai[bot] dd5832a8fc Merge pull request #2679 from Molecule-AI/staging
staging → main: auto-promote c5d8ce9
2026-05-04 03:05:22 +00:00
Hongming Wang 8622829848 Merge pull request #2677 from Molecule-AI/auto-sync/main-81c8a8b3
chore: sync main → staging (auto, ff to 81c8a8b3)
2026-05-04 02:51:51 +00:00
Hongming Wang c5d8ce9ffe Merge pull request #2678 from Molecule-AI/a11y/canvas-terminal-tab-chrome
canvas/TerminalTab: semantic status colors + accent Reconnect
2026-05-04 02:47:48 +00:00
Hongming Wang 90b561add0 canvas/TerminalTab: semantic status colors + accent Reconnect button
Three small UIUX fixes for the workspace terminal status bar.

1. Status dots were hardcoded bg-green-500 / bg-yellow-500 /
   bg-red-500 / bg-zinc-500 — semantic-token misses. Switched to
   bg-good / bg-warm / bg-bad / bg-ink-soft so the colors flip with
   the canvas-wide ramp instead of pinning Tailwind raw values.

2. Reconnect button used hardcoded text-blue-400 / hover:text-blue-300
   with no focus ring. Switched to text-accent / hover:text-accent-strong
   for theme parity, and added focus-visible:ring-accent/60 so
   keyboard users see where focus lands on a recovery action.

3. Error banner used text-red-400 — switched to text-bad to match the
   semantic ramp.

Status-bar bg/border kept as zinc (terminal body stays dark
unconditionally per the Canvas v4 design rule); only the chrome's
foreground tokens needed semanticisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:45:24 -07:00
molecule-ai[bot] 81c8a8b35d Merge pull request #2676 from Molecule-AI/staging
staging → main: auto-promote 408e308
2026-05-04 02:39:37 +00:00
Hongming Wang 7ce0138150 Merge pull request #2675 from Molecule-AI/auto-sync/main-05596803
chore: sync main → staging (auto, ff to 05596803)
2026-05-04 02:23:31 +00:00
Hongming Wang 408e308ce5 Merge pull request #2674 from Molecule-AI/a11y/canvas-events-tab
canvas/EventsTab: theme-flip event colors + a11y for expander rows
2026-05-04 02:20:17 +00:00
molecule-ai[bot] 05596803f7 Merge pull request #2673 from Molecule-AI/staging
staging → main: auto-promote 754e5b2
2026-05-03 19:18:52 -07:00
Hongming Wang 6cd650f48c canvas/EventsTab: theme-flip event colors + a11y for expander rows
Four UIUX fixes for the workspace Events tab.

1. Hardcoded text-yellow-400 (DEGRADED) and text-purple-400
   (AGENT_CARD_UPDATED) didn't theme-flip — read fine in dark mode,
   washed out in warm-paper light. Switched DEGRADED → text-warm
   (the semantic warm/amber token) and AGENT_CARD_UPDATED → text-
   accent (informational metadata, accent is the right semantic).

2. Refresh button hover was a no-op (bg-surface-card on top of itself).
   Lift to surface-elevated, matching the Cancel pattern from
   ConfirmDialog. Added focus-visible ring.

3. Event expander rows had no aria-expanded — screen readers heard a
   generic "button" with no indication it toggled. Added
   aria-expanded + aria-controls pointing to the payload panel id.

4. Added focus-visible ring on each expander button. Hover bg added
   too so the active row visibly responds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:17:42 -07:00
Hongming Wang 754e5b2da1 Merge pull request #2672 from Molecule-AI/auto-sync/main-68c9bd8f
chore: sync main → staging (auto, ff to 68c9bd8f)
2026-05-04 02:03:31 +00:00
Hongming Wang f23665d4d9 Merge pull request #2671 from Molecule-AI/a11y/canvas-onboarding-wizard
canvas/OnboardingWizard: theme-flip colors + fix hover traps + focus rings
2026-05-04 01:52:14 +00:00
molecule-ai[bot] 68c9bd8fe4 Merge pull request #2670 from Molecule-AI/staging
staging → main: auto-promote c4d476d
2026-05-04 01:50:10 +00:00
Hongming Wang 4d747de218 canvas/OnboardingWizard: theme-flip colors + fix hover traps + focus rings
Five fixes for the first-time-user wizard. Every new user sees this,
so visual bugs here have outsized impact.

1. Action button hovered LIGHTER: bg-accent-strong/90 hover:bg-accent.
   accent is the LIGHTER variant — hovering to it on white text drops
   contrast below AA. Flipped the direction: bg-accent
   hover:bg-accent-strong, matching the same trap fixed in
   ConfirmDialog and ApprovalBanner.

2. "Next" button hover was a no-op (bg-surface-card on top of itself).
   Lift to surface-elevated, matching the Cancel pattern in
   ConfirmDialog.

3. Progress bar gradient was hardcoded from-blue-500 to-sky-400 —
   neither tone exists in the warm-paper light theme, so the bar lost
   brand color in light mode. Switched to the accent ramp so it stays
   brand-tinted in both.

4. Step indicator was hardcoded text-sky-400/80, same theme-flip
   issue. Switched to text-accent.

5. All three buttons (Skip / Action / Next) had no focus-visible
   rings. Added the accent ring pattern used across the rest of
   the canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:49:19 -07:00
Hongming Wang 4a8a72f4ae Merge pull request #2667 from Molecule-AI/auto-sync/main-ad24703d
chore: sync main → staging (auto, ff to ad24703d)
2026-05-04 01:36:50 +00:00
Hongming Wang c4d476d0dc Merge pull request #2668 from Molecule-AI/fix/synth-e2e-verify-actually-skips-job
fix(synth-e2e): verify-secrets must hard-fail (exit 0 only ends step)
2026-05-04 01:34:52 +00:00
Hongming Wang 9689c6f6d5 fix(synth-e2e): verify-secrets step must hard-fail (exit 0 only ends step)
The previous soft-skip-on-dispatch path used `exit 0`, which only
ends the STEP — the rest of the workflow continued with empty
secrets. Caught 2026-05-04 by dispatched run 25296530706:
  - E2E_MINIMAX_API_KEY: empty
  - verify-secrets printed warning + exit 0
  - Install required tools: ran
  - Run synthetic E2E: ran with empty MiniMax key
  - SECRETS_JSON branched to OpenAI shape (MINIMAX empty → fall through)
  - But model slug stayed MiniMax-M2.7-highspeed (workflow env)
  - Workspace booted with OpenAI keys + MiniMax model
  - 5 min later: "Agent error (Exception)" — claude SDK 401'd
    against api.minimax.io with the OpenAI key

The confusing failure mode silently masked the real problem (missing
secret) under a runtime-error label. Fix: drop both soft-skip paths
and exit 1 always. Operators who want to verify a YAML change without
setting up secrets can read the verify-secrets step's stderr — the
failure IS the verification signal.

Pure visibility fix; preserves the cron hard-fail path (now also the
dispatch hard-fail path). No mechanism change beyond the exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:32:26 -07:00
Hongming Wang 3e4ff1ce9c Merge pull request #2666 from Molecule-AI/a11y/canvas-terms-gate
canvas/TermsGate: stop hiding the dialog from screen readers + a11y polish
2026-05-04 01:24:26 +00:00
Hongming Wang ad24703d74 Merge pull request #2665 from Molecule-AI/staging
staging → main: auto-promote d684e28
2026-05-03 18:23:38 -07:00
Hongming Wang 3e6c7075d0 canvas/TermsGate: stop hiding the dialog from screen readers + a11y polish
Five fixes for the terms-acceptance modal:

1. CRITICAL: aria-hidden="true" on the modal's wrapper hid the dialog
   AND its descendants from screen readers. The entire ToS-acceptance
   flow was invisible to AT users. Removed the false aria-hidden — the
   wrapper is just a backdrop, the dialog inside still has role=dialog
   aria-modal=true so AT recognises it correctly.

2. Added focus management: when the modal opens, focus moves to the
   "I agree" button (WCAG 2.4.3). Hard gate so no focus-trap loop or
   Esc-dismiss — the user must accept or close the page.

3. "I agree" button hovered LIGHTER (bg-emerald-500 over bg-emerald-600).
   On white text that drops below AA — same trap fixed in ApprovalBanner
   and ConfirmDialog. Flipped to bg-emerald-700.

4. Added focus-visible ring on the "I agree" button. Was relying on
   browser default outline only.

5. Privacy/Terms links: hardcoded text-sky-400 → text-accent (theme-
   aware) + hover:text-accent-strong (was hover:text-sky-400, no-op
   same color) + focus-visible ring. Added aria-describedby pointing
   to the body div so SR can read the description with the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:21:42 -07:00
Hongming Wang 390425afbc Merge pull request #2664 from Molecule-AI/feat/canvas-agent-comms-waiting-bubble
canvas/AgentCommsPanel: per-peer waiting-for-reply bubble
2026-05-04 01:05:08 +00:00
Hongming Wang 663c5b7e70 canvas/AgentCommsPanel: add per-peer waiting-for-reply bubble
Mirrors the bouncing-dots indicator ChatTab already shows while waiting
for an agent reply. Before this, an operator delegating to one or more
external peers via Agent Comms saw their outbound bubble land and then
silence until the reply (or queued/failed status) arrived — no visual
"the system is working on this" cue.

Per-peer not global: when multiple delegations are in flight to
different peers (the fan-out case), one shared spinner under-reports —
the user can't tell whether ALL peers are still working or just the
visible ones. Per-peer matches Slack typing-indicator semantics and
keeps the signal honest.

Detection rule: walk visible messages, keep only the chronologically-
last bubble per peer. If that tail is `flow === "out"` AND status is
"pending" or "queued", emit a waiting bubble. Once an inbound reply
lands, the tail flips to "in" and the bubble disappears — even if the
backend hasn't mutated the original outbound row to "completed" yet.
This collapses both states into one rule.

Visual: matches the outgoing bubble (cyan-900/30 + cyan-700/20 border,
right-justified) with cyan-300/70 dots that respect prefers-reduced-
motion via `motion-safe:animate-bounce`. Queued case adds copy
explaining the peer is busy. role="status" + aria-label so SR users
also hear "Waiting for reply from <peer>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:02:30 -07:00
Hongming Wang b70d857409 Merge pull request #2663 from Molecule-AI/a11y/canvas-delete-cascade-buttons
canvas/DeleteCascadeConfirmDialog: fix Cancel/Delete hovers + focus rings
2026-05-04 01:00:24 +00:00
Hongming Wang 2f89a05f2f Merge branch 'staging' into a11y/canvas-delete-cascade-buttons 2026-05-03 17:55:48 -07:00
Hongming Wang d684e28228 Merge pull request #2662 from Molecule-AI/auto-sync/main-e5a8ace6
chore: sync main → staging (auto, ff to e5a8ace6)
2026-05-04 00:54:33 +00:00
Hongming Wang 71fb499dee canvas/DeleteCascadeConfirmDialog: fix Cancel no-op hover + Delete light hover + focus rings
Four fixes for the cascade-delete confirmation modal:

1. Cancel button hover was a no-op: bg-surface-card on top of the
   same base — clicking did something but the button looked dead.
   Lifted to surface-elevated, matching the ConfirmDialog Cancel
   pattern.

2. Delete button hovered LIGHTER (bg-red-500 over bg-red-600). On
   white text that drops contrast below AA — same trap fixed in
   ConfirmDialog and ApprovalBanner. Flipped to bg-red-700 so hover
   stays readable in both themes.

3. Checkbox ring-offset color was zinc-900 — but the dialog actually
   sits on bg-surface-sunken, so the offset showed the wrong color
   through the ring gap. Corrected to ring-offset-surface-sunken.
   Also moved focus → focus-visible so the ring only shows on
   keyboard nav, not mouse clicks.

4. Cancel + Delete had no focus-visible rings. Added accent ring
   on Cancel, danger ring on Delete, both with the correct
   ring-offset-surface-sunken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:53:29 -07:00
Hongming Wang e5c9656016 Merge pull request #2661 from Molecule-AI/feat/external-connect-modal-help-section
feat(external-connect): fix Claude Code channel snippet + add per-tab Help section to ExternalConnectModal
2026-05-04 00:50:10 +00:00
molecule-ai[bot] e5a8ace677 Merge pull request #2660 from Molecule-AI/staging
staging → main: auto-promote 166c677
2026-05-03 17:48:42 -07:00
Hongming Wang d5eb58af56 feat(external-connect): comprehensive setup — fix Claude Code channel snippet + add per-tab Help section
User report: handing the modal's Claude Code channel snippet to an
agent fails immediately with two errors that the snippet doesn't tell
the operator how to resolve:

  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · plugin not installed
  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · not on the approved channels allowlist

Root cause: the snippet's `claude --channels plugin:...` line assumes
the plugin is pre-installed AND that the channel is on Anthropic's
default allowlist. Both assumptions are wrong for a custom Molecule
plugin in a public repo.

Two changes:

1. Rewrite externalChannelTemplate (Go) with full setup chain:
   - Bun prereq check (channel plugins are Bun scripts)
   - `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel`
     + `/plugin install molecule@molecule-mcp-claude-channel` BEFORE the
     launch — otherwise "plugin not installed"
   - `--dangerously-load-development-channels` flag on launch — required
     for non-Anthropic-allowlisted channels, otherwise "not on approved
     channels allowlist"
   - Common-errors block at the bottom mapping each error string to
     which numbered step recovers it
   - Team/Enterprise managed-settings caveat (the dev-channels flag is
     blocked there; admin must use channelsEnabled + allowedChannelPlugins)

   Plugin install info verified by reading `Molecule-AI/molecule-mcp-claude-channel`
   plugin.json (`name: "molecule"`) and the Claude Code channels +
   plugin-discovery docs at code.claude.com/docs/en/{channels,discover-plugins}.

2. Add per-tab HelpBlock to the modal (canvas):
   - Collapsible <details> below each snippet, closed by default so the
     snippet stays the visual focus
   - "Where to install" link (PyPI for runtime, claude.com for Claude
     Code, github.com/openai/codex for Codex, NousResearch/hermes-agent
     for Hermes)
   - "Documentation" link (docs.molecule.ai/docs/guides/*; hostname
     confirmed by existing blog post canonical metadata; paths map
     1:1 to docs/guides/*.md files in this repo)
   - "Common errors" list with concrete recovery steps for each tab
     (e.g. Codex tab calls out the codex≥0.57 requirement and TOML
     duplicate-table parse error; OpenClaw calls out the :18789 port
     conflict check)

   URL discipline: every URL is either (a) verified against a file path
   in this repo's docs/, (b) the canonical repo of an existing snippet
   reference, or (c) a well-known third-party canonical URL. No guessed
   URLs — broken links would defeat the purpose of "more comprehensive
   instructions."

Verification:
- `go build ./...` clean in workspace-server
- `go test ./internal/handlers/...` passes (4.3s)
- Bash syntax check on test_staging_full_saas.sh (no edits there) clean
- TS brace/paren/bracket counts balanced; no full tsc run because the
  worktree's node_modules isn't installed — counterpart Canvas tabs E2E
  on the PR will exercise the full type-check + render path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:46:55 -07:00
Hongming Wang 166c677a09 Merge pull request #2659 from Molecule-AI/fix/synth-e2e-cron-dodge-top-of-hour
ci(synth-e2e): move cron off :00 to dodge GH scheduler drops (closes #273)
2026-05-04 00:31:05 +00:00
Hongming Wang a7f1b378de Merge pull request #2658 from Molecule-AI/a11y/canvas-bundle-drop-zone
canvas/BundleDropZone: theme-flip overlay + SR announce + reduced-motion
2026-05-04 00:28:54 +00:00
Hongming Wang a306a97dd3 ci(synth-e2e): move cron off :00 to dodge GH scheduler drops
GitHub Actions scheduler de-prioritises :00 cron firings under load.
Empirical 2026-05-03: the canary's cron was '0,20,40 * * * *' but
actual firings landed at :08, :03, :01, :03 — :20 and :40 silently
dropped. Detection latency degraded from claimed 20 min to actual
~60 min worst case.

Move to '10,30,50 * * * *':
- :10/:30/:50 sit 10 min off the top-of-hour load peak
- Still 5 min from :15 sweep-cf-orphans and :45 sweep-cf-tunnels
  (the original constraint that kept us off :15/:45)
- Same 20-min cadence; only the phase changes

No code change beyond the cron expression + comment refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:28:45 -07:00
Hongming Wang ec54942628 canvas/BundleDropZone: theme-flip drag overlay + announce import + reduced-motion
Three small UIUX fixes for the bundle drag-import surface.

1. Drag overlay was hardcoded blue-950/blue-400 — those tones don't
   exist in the warm-paper light theme, so the overlay washed out
   inconsistently. Switched to bg-accent/15 + border-accent/40 so
   the overlay flips with theme and matches the inner card's
   border-accent/50.

2. Importing spinner was visually obvious but invisible to screen
   readers — only the result toast had aria-live. Operators relying
   on AT had no way to know the import was in flight. Added
   role="status" + aria-live="polite" + aria-hidden on the spinner
   itself so the SR hears "Importing bundle..." once.

3. animate-spin → motion-safe:animate-spin so the spinner respects
   prefers-reduced-motion (Tailwind's built-in variant gates the
   animation on the user's OS setting). Layout doesn't change in
   either case — text alone communicates state.

Also dropped border-sky-400 → border-accent on the spinner so it
matches the rest of the canvas semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:26:15 -07:00
Hongming Wang 065e39dda2 Merge pull request #2657 from Molecule-AI/auto-sync/main-54d32d1e
chore: sync main → staging (auto, ff to 54d32d1e)
2026-05-04 00:23:24 +00:00
molecule-ai[bot] 54d32d1ee2 Merge pull request #2656 from Molecule-AI/staging
staging → main: auto-promote 4cd01a2
2026-05-04 00:19:09 +00:00
Hongming Wang 4cd01a2df1 Merge pull request #2654 from Molecule-AI/auto-sync/main-8760ee16
chore: sync main → staging (auto, ff to 8760ee16)
2026-05-04 00:03:47 +00:00
Hongming Wang ccb7ca5d8a Merge pull request #2655 from Molecule-AI/a11y/canvas-console-modal-buttons
canvas/ConsoleModal: fix no-op hovers + Copy feedback + focus rings
2026-05-04 00:01:13 +00:00
Hongming Wang 10f2b9f01c canvas/ConsoleModal: fix no-op hovers + add Copy success feedback
Four UIUX fixes for the EC2 console modal:

1. Copy and Close buttons had hover:bg-surface-card on TOP of the
   same base bg-surface-card — silent no-op hover. Lifted to
   surface-elevated + line-soft border, matching ConfirmDialog's
   Cancel pattern. The button visibly responds now.

2. Copy button silently succeeded — no toast, no animation, no UI
   feedback. Operators clicking it had no idea whether anything
   landed in the clipboard. Now fires showToast on resolve/reject
   so the action is observable.

3. × close button was ~10x16px (well under WCAG 2.5.5's 24x24).
   Bumped to w-6 h-6 with focus-visible ring + hover bg.

4. Added focus-visible:ring-accent/60 + ring-offset-surface to
   all three buttons so keyboard users see focus. Matches the
   semantic ring pattern used across the canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:58:31 -07:00
molecule-ai[bot] 8760ee1628 Merge pull request #2653 from Molecule-AI/staging
staging → main: auto-promote e9fdd99
2026-05-03 16:51:55 -07:00
Hongming Wang 28f5108a7c Merge pull request #2652 from Molecule-AI/a11y/canvas-batch-action-esc
canvas/BatchActionBar: wire Esc to clear selection
2026-05-03 23:33:54 +00:00
Hongming Wang e9fdd992a9 Merge pull request #2651 from Molecule-AI/auto-sync/main-aedbbc4a
chore: sync main → staging (auto, ff to aedbbc4a)
2026-05-03 23:33:41 +00:00
Hongming Wang f6fa3669dc Merge pull request #2650 from Molecule-AI/fix/sweep-stale-e2e-port-verify-pattern
ci: port DELETE-verify pattern to remaining staging e2e workflows
2026-05-03 23:32:42 +00:00
Hongming Wang b1a1c8e4a9 canvas/BatchActionBar: wire Esc to clear selection (matches button title)
Two small fixes for the batch-action toolbar:

1. The deselect button's title says "Clear selection (Escape)" — but
   pressing Escape did NOTHING. The title has been lying since the bar
   shipped. Now wired: window keydown handler calls clearSelection
   when Esc fires. Skipped while the confirm dialog is open
   (`pending !== null`) so the dialog's own Esc-cancels takes
   precedence, and skipped during a busy in-flight action so the
   user can't strand a partial-failure mid-flight.

2. focus-visible:ring-zinc-500/70 → focus-visible:ring-accent/50
   on the deselect button. The hardcoded zinc broke the semantic-
   token pattern used by the other action buttons.

Tests: two new vitest cases — Esc clears with selection, Esc no-op
when empty (the bar isn't mounted at count===0 so the listener never
registers). Full suite: 1222/1222.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:23 -07:00
molecule-ai[bot] aedbbc4a10 Merge pull request #2649 from Molecule-AI/staging
staging → main: auto-promote 98da627
2026-05-03 23:27:44 +00:00
Hongming Wang 8b9e7e6d59 ci: port DELETE-verify pattern to remaining staging e2e workflows
Follow-up to #2648 — same `>/dev/null || true` swallow-on-error
pattern existed in:

  e2e-staging-canvas.yml   (single-slug)
  e2e-staging-saas.yml     (loop)
  e2e-staging-sanity.yml   (loop)
  e2e-staging-external.yml (loop, was `>/dev/null 2>&1` variant)

All four now capture the HTTP code, log a "[teardown] deleted $slug
(HTTP $code)" line on success, and emit a workflow warning naming
the slug + body excerpt on non-2xx. Loop bodies also tally + summarise
total leaks at the end.

Exit semantics unchanged: a single cleanup miss still doesn't fail-flag
the test (sweep-stale-e2e-orgs is the safety net within ~45 min). The
behavior change is purely surfacing — failures that were silent are
now visible on the workflow run page.

Pairs with #2648's tightened sweeper. Together: per-run cleanup
failures are visible AND the safety net catches them quickly.

Closes the per-workflow port noted as out-of-scope in #2648.
See molecule-controlplane#420.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:24:43 -07:00
Hongming Wang 3c127ae3b9 Merge pull request #2647 from Molecule-AI/a11y/canvas-legend-focus-touch
canvas/Legend: focus rings + 24x24 close-button touch target
2026-05-03 23:13:33 +00:00
Hongming Wang 98da627170 Merge pull request #2648 from Molecule-AI/fix/sweep-stale-e2e-tighter-threshold
ci: tighten e2e cleanup race window 120m → ~45m worst case
2026-05-03 23:12:22 +00:00
Hongming Wang 3cd8c53de0 ci: tighten e2e cleanup race window 120m -> ~45m worst case
Two changes that close one of the leak classes from the
molecule-controlplane#420 vCPU audit:

1. sweep-stale-e2e-orgs.yml: cron */15 (was hourly), MAX_AGE_MINUTES
   30 (was 120). E2E runs are 8-25 min wall clock; 30 min is safely
   above the longest run while shrinking the worst-case leak window
   from ~2h to ~45 min (15-min sweep cadence + 30-min threshold).

2. canary-staging.yml teardown: the per-slug DELETE used `>/dev/null
   || true`, which swallowed every failure. A 5xx or timeout from CP
   looked identical to "successfully deleted" and the canary tenant
   kept eating ~2 vCPU until the sweeper caught it. Now we capture
   the response code and surface non-2xx as a workflow warning that
   names the leaked slug.

The exit semantics stay unchanged — a single-canary cleanup miss
shouldn't fail-flag the canary itself when the actual smoke check
passed. The sweeper is the safety net for whatever slips past.

Caught during the molecule-controlplane#420 audit on 2026-05-03 —
3 e2e canary tenant orphans were running for 24-95 min, all under
the previous 120-min sweep threshold so they went unnoticed until
manual cleanup. Same `|| true` pattern exists in
e2e-staging-{canvas,external,saas,sanity}.yml; out of scope for
this PR (mechanical port; tracking separately) but the sweeper
tightening covers all of them by reducing the safety-net latency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:08:40 -07:00
Hongming Wang 69fcfe9b3a Merge pull request #2646 from Molecule-AI/auto-sync/main-1141a429
chore: sync main → staging (auto, ff to 1141a429)
2026-05-03 23:05:05 +00:00
Hongming Wang 24d64677ab canvas/Legend: focus rings + 24x24 close-button touch target
Two small a11y fixes for the floating legend.

1. Both buttons (open pill + close ×) had no focus-visible ring —
   keyboard users couldn't tell where focus landed. Added the
   accent-ring pattern used across the rest of the canvas.

2. Close button was a ~10x16px hit area — well below WCAG 2.5.5's
   24x24 minimum. Bumped to w-6 h-6 with negative margin so the
   visible × stays in the same spot but the hit area + focus ring
   are larger. Hover bg added to make the hit area visible on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:04:04 -07:00
molecule-ai[bot] 1141a42910 Merge pull request #2645 from Molecule-AI/staging
staging → main: auto-promote f689c81
2026-05-03 22:54:00 +00:00
Hongming Wang 84448d452b Merge pull request #2644 from Molecule-AI/a11y/canvas-cookie-consent-not-modal
canvas/CookieConsent: stop claiming aria-modal without a focus trap
2026-05-03 22:39:44 +00:00
Hongming Wang f689c81a70 Merge pull request #2642 from Molecule-AI/auto-sync/main-bae27270
chore: sync main → staging (auto, ff to bae27270)
2026-05-03 22:38:47 +00:00
Hongming Wang 2268027581 Merge pull request #2643 from Molecule-AI/feat/synth-e2e-claude-code-minimax
feat(synth-e2e): switch canary to claude-code + MiniMax-M2.7-highspeed
2026-05-03 22:38:35 +00:00
Hongming Wang 652124284b canvas/CookieConsent: stop pretending to be a modal + fix link/button focus
Three fixes for the cookie banner:

1. role="dialog" aria-modal="true" → <section role="region">. The
   banner has no focus trap, doesn't block the page, and the user
   can keep using the canvas while it's up — none of which are modal
   semantics. Claiming aria-modal="true" without a trap actively
   harms screen-reader users: they're told the rest of the page is
   inert, jump into the banner, and then can't escape. Region
   semantics let AT navigate around it normally. (Forcing a modal
   cookie banner would also be a dark pattern under GDPR.)

2. Privacy-policy link: hover:text-accent → hover:text-accent-strong.
   The original was a no-op (same color). Also added focus-visible
   ring + underline-offset so the link is readable AND keyboard-
   distinguishable in both themes.

3. Both buttons: focus-visible:ring-2 + ring-offset-surface so
   keyboard users see where focus lands. Mouse clicks unchanged
   thanks to focus-visible.

Tests: swapped getByRole("dialog") → getByRole("region") in 8
existing tests, then tightened the role-assertion test into a
regression guard that explicitly asserts NO aria-modal and NO
dialog role exist. Full suite: 1220/1220.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:37:06 -07:00
Hongming Wang 79a0203798 feat(synth-e2e): switch canary to claude-code + MiniMax-M2.7-highspeed
Cuts the per-run LLM cost ~10x (MiniMax M2.7 vs gpt-4.1-mini) and
removes the recurring OpenAI-quota-exhaustion failure mode that took
the canary down on 2026-05-03 (#265 — staging quota burnt for ~16h).

Path:
  E2E_RUNTIME=claude-code (default)
  → workspace-configs-templates/claude-code-default/config.yaml's
    `minimax` provider (lines 64-69)
  → ANTHROPIC_BASE_URL auto-set to api.minimax.io/anthropic
  → reads MINIMAX_API_KEY (per-vendor env, no collision with
    GLM/Z.ai etc.)

Workflow changes (continuous-synth-e2e.yml):
- Default runtime: langgraph → claude-code
- New env: E2E_MODEL_SLUG (defaults to MiniMax-M2.7-highspeed,
  overridable via workflow_dispatch)
- New secret wire: E2E_MINIMAX_API_KEY ←
  secrets.MOLECULE_STAGING_MINIMAX_API_KEY
- Per-runtime missing-secret guard: claude-code requires MINIMAX,
  langgraph/hermes require OPENAI. Cron firing hard-fails on missing
  key for the active runtime; dispatch soft-skips so operators can
  ad-hoc test without setting up the secret first
- Operators can still pick langgraph/hermes via workflow_dispatch;
  the OpenAI fallback path stays wired

Script changes (tests/e2e/test_staging_full_saas.sh):
- SECRETS_JSON branches on which key is set:
    E2E_MINIMAX_API_KEY → {MINIMAX_API_KEY: <key>}  (claude-code path)
    E2E_OPENAI_API_KEY  → {OPENAI_API_KEY, HERMES_*, MODEL_PROVIDER}  (legacy)
  MiniMax wins when both are present — claude-code default canary
  must not accidentally consume the OpenAI key

Tests (new tests/e2e/test_secrets_dispatch.sh):
- 10 cases pinning the precedence + payload shape per branch
- Discipline check verified: 5 of 10 FAIL on a swapped if/elif
  (precedence inversion), all 10 PASS on the fix
- Anchors on the section-comment header so a structural refactor
  fails loudly rather than silently sourcing nothing

The model_slug dispatcher (lib/model_slug.sh) needs no change:
E2E_MODEL_SLUG override path is already wired (line 41), and
claude-code template's `minimax-` prefix matcher catches
"MiniMax-M2.7-highspeed" via lowercase-on-lookup.

Operator action required to land green:
- Set MOLECULE_STAGING_MINIMAX_API_KEY in repo secrets
  (Settings → Secrets and Variables → Actions). Use
  `gh secret set MOLECULE_STAGING_MINIMAX_API_KEY -R Molecule-AI/molecule-core`
  to avoid leaking the value into shell history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:35:14 -07:00
molecule-ai[bot] bae2727074 Merge pull request #2641 from Molecule-AI/staging
staging → main: auto-promote 2e96860
2026-05-03 15:33:45 -07:00
Hongming Wang 2c4d92d9bc Merge pull request #2640 from Molecule-AI/fix/canary-classify-provider-quota
test(e2e): canary classifies provider-quota 429 as operator-action, not platform regression
2026-05-03 22:21:01 +00:00
Hongming Wang 4c49ff75f6 test(e2e): canary classifies provider-quota 429 as operator-action, not platform regression
The staging canary's A2A step has a ladder of specific regression
classifiers (hermes-agent down, model_not_found, Invalid API key,
etc.) followed by a generic "error|exception" catch-all. Provider-
side OpenAI 429 quota errors fell through to the catch-all, so the
canary issue body and CI log just said "A2A returned an error-shaped
response" — which is technically true but obscures the actual
operator action.

This adds a 7th classifier above the catch-all for "exceeded your
current quota" / "insufficient_quota" — both terms appear in
OpenAI's quota-exhaustion 429 response. When matched, the failure
message names the operator action directly (top up MOLECULE_STAGING_OPENAI_KEY
or rotate the secret) and links to #2578.

Why this is correct, not "lowering the bar":
- Steps 0–7 of the canary cover full platform health (CP up, tenant
  provisioned, DNS+TLS reachable, workspace booted, A2A delivered).
- Reaching step 8 with a provider-side 429 means the platform IS
  healthy — the failure is downstream of all platform invariants.
- The canary still exits 1 (CI stays red, threshold-3 alarm still
  fires); only the failure message changes.
- All 6 existing specific classifiers run BEFORE this one, so any
  real platform regression is still caught with its specific message.

Verification:
- Regex tested against the actual 429 string from canary run 25291517608:
    "API call failed after 3 retries: HTTP 429: You exceeded your current quota..."
  → matches 
- Negative tests: "PONG", "hermes-agent unreachable" → no match 
- bash -n syntax check passes
- shellcheck -S error clean

Tracking: #2593 (canary), #2578 (root cause)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:18:42 -07:00
Hongming Wang 2e9686036d Merge pull request #2639 from Molecule-AI/a11y/canvas-search-dialog-first-match
canvas/SearchDialog: auto-highlight first match + semantic placeholder
2026-05-03 22:11:31 +00:00
Hongming Wang 2bc304bfd3 Merge pull request #2638 from Molecule-AI/auto-sync/main-d2c0041b
chore: sync main → staging (auto, ff to d2c0041b)
2026-05-03 22:10:16 +00:00
Hongming Wang 7ca764f917 canvas/SearchDialog: auto-highlight first match + semantic placeholder
Two small UIUX fixes for Cmd+K search.

1. Auto-highlight the first match while the user types. Before, Enter
   on a non-empty query was a no-op — focusedIndex stayed at -1 until
   the user pressed ↓. Standard search-palette behavior is to highlight
   the top result so Enter just works. Empty query keeps -1 (opening
   the dialog shows ALL workspaces; arbitrarily pinning one looks
   wrong).

2. placeholder-zinc-400 → placeholder-ink-soft. The hardcoded zinc
   broke the semantic-token pattern other inputs use; placeholder now
   flips with theme correctly. (Also reordered focus:outline-none
   ahead of the focus-visible variants — cosmetic, more idiomatic.)

Tests: replaced the "resets to -1" test with two new ones — auto-
highlight on a matching query (Enter selects without ArrowDown), and
no-results query stays a no-op. Full suite 1220/1220.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:09:01 -07:00
molecule-ai[bot] d2c0041b2b Merge pull request #2637 from Molecule-AI/staging
staging → main: auto-promote 149d0bf
2026-05-03 15:03:39 -07:00
Hongming Wang 149d0bf3d7 Merge pull request #2635 from Molecule-AI/auto-sync/main-e4db4cfb
chore: sync main → staging (auto, ff to e4db4cfb)
2026-05-03 14:48:58 -07:00
Hongming Wang c6eec15292 Merge pull request #2636 from Molecule-AI/a11y/canvas-context-menu-clamp
canvas/ContextMenu: clamp to viewport + semantic focus ring
2026-05-03 21:43:00 +00:00
Hongming Wang 68f8fa2621 canvas/ContextMenu: clamp position to viewport + semantic focus ring
Two small fixes for the workspace right-click menu:

1. Off-screen clamp. Right-clicking near the right or bottom edge of
   the canvas put part of the menu past the viewport — items hidden
   under the scrollbar / off the screen. The menu now measures itself
   on the same rAF that auto-focuses the first item, and shifts back
   inside with an 8px margin (matching the floating-tooltip top-edge
   clamp in Tooltip.tsx). Falls back to the raw cursor coords for the
   first paint frame so there's no flash.

2. focus:ring-zinc-600 → focus-visible:ring-accent/50. The hardcoded
   zinc tone broke the semantic-token pattern every other surface
   uses; flipping to focus-visible also stops the ring from showing
   when items are clicked with the mouse (only keyboard nav now
   triggers the ring, matching Toolbar/SidePanel behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:40:18 -07:00
Hongming Wang e4db4cfb11 Merge pull request #2625 from Molecule-AI/staging
staging → main: auto-promote 9fd52e9
2026-05-03 14:38:42 -07:00
Hongming Wang 65b42c33b9 Merge pull request #2634 from Molecule-AI/chore/canvas-e2e-error-detail
canvas/e2e: surface admin-orgs row + workspace body on failure
2026-05-03 21:05:25 +00:00
Hongming Wang 9d45211fd3 canvas/e2e: surface admin-orgs row + workspace body on failure
Two diagnostic upgrades to the Playwright staging-setup harness, both
zero-behavior-change:

1. provision-failed throw now includes the full admin-orgs row (boot
   stage, last error, terraform/SSM state, etc) instead of just the
   slug. Every "provision failed: <slug>" in CI history was followed
   by a manual repro to find out WHY — that round-trip is gone.

2. workspace-failed throw dumps the full /workspaces/{id} body when
   last_sample_error is empty. Boot crashes, image-pull errors,
   missing PYTHONPATH, and OpenAI-quota-at-startup all surface as a
   bare "Workspace failed:" today (see #2632). Now they carry the
   boot_stage / image / last_error fields the API row exposes.

No fix for the underlying flakes — those are tracked in #2632 (CP race)
and #2578 (OpenAI quota). This just stops them looking identical in the
CI log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:01:50 -07:00
Hongming Wang 14494fe67c Merge pull request #2633 from Molecule-AI/a11y/canvas-toaster-esc-focus
canvas/Toaster: Esc dismiss + focus ring + bigger touch target
2026-05-03 20:58:02 +00:00
Hongming Wang 3b244ca6c6 canvas/Toaster: add Esc dismiss + focus-visible ring + larger touch target
Three small a11y fixes for the global toast surface:

1. Esc dismisses the newest toast. Errors never auto-expire, so without
   a keyboard shortcut a keyboard-only user has to tab through the entire
   app to reach the × button on a stuck error.

2. Dismiss button gets focus-visible ring + theme-aware tint. The previous
   `opacity-70 hover:opacity-100` gave no visible focus indicator (WCAG
   2.4.7). Info toasts use the semantic surface that flips with theme,
   so the dismiss tint splits per type — accent ring on info, white ring
   on the always-dark success/error toasts.

3. Touch target bumps from p-1 (~24x24) to w-7 h-7 (28x28) toward WCAG
   2.5.5 AAA's 44x44 ideal.

Tests: 5 new vitest cases covering Esc on info/error, no-op on empty
queue, accessible label, and per-toast click dismissal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:55:24 -07:00
Hongming Wang 18e88e7039 Merge pull request #2630 from Molecule-AI/a11y/canvas-tooltip-esc-dismiss
a11y(canvas): Tooltip Esc-to-dismiss (WCAG 1.4.13)
2026-05-03 20:36:44 +00:00
Hongming Wang f7d663d19a Merge pull request #2629 from Molecule-AI/feat/external-connect-hermes-tab
feat(canvas): add Hermes/Codex/OpenClaw tabs to ExternalConnectModal + default to Universal MCP
2026-05-03 20:29:41 +00:00
Hongming Wang c8e422f6c6 Merge pull request #2627 from Molecule-AI/fix/canvas-chat-agent-prose-brightness
fix(canvas): brighten agent chat prose body in dark mode
2026-05-03 20:29:33 +00:00
Hongming Wang 1d303ee75e a11y(canvas): Tooltip Esc-to-dismiss (WCAG 1.4.13)
WCAG 1.4.13 (Content on Hover or Focus) requires that tooltip content
be DISMISSIBLE without moving pointer hover or keyboard focus. Tooltip
had no escape hatch — once a keyboard user tabbed onto a control with
a tooltip, the tooltip stayed visible until they tabbed away (which
moves focus and may not be possible if the tooltip is itself blocking
content the user needs to see, e.g. for screen-magnifier users).

Add a window-level Escape listener that's active only while a tooltip
is shown. Pressing Esc clears the tooltip without moving focus or
breaking the hover state, satisfying the dismissible criterion.

Used `capture: true` so we beat any modal/dialog Esc handler that
might also be listening — the tooltip belongs to the focused control,
not the modal it sits inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:23:08 -07:00
Hongming Wang 1ec7e4af6d Merge branch 'staging' into feat/external-connect-hermes-tab 2026-05-03 13:16:32 -07:00
Hongming Wang ae4739f35b Merge branch 'staging' into fix/canvas-chat-agent-prose-brightness 2026-05-03 13:16:25 -07:00
Hongming Wang 6f203c5646 Merge pull request #2628 from Molecule-AI/test/synth-e2e-eic-terminal-probe
test(e2e): add canvas-terminal diagnose probe to synth-E2E (#269 partial)
2026-05-03 20:14:50 +00:00
Hongming Wang ff0d4dae77 fix(external-connect): address self-review criticals — config corruption + durability
Self-review of the modal-tab additions caught footguns in the new
hermes/codex/openclaw snippets. Ship the fixes before merge.

Critical 1 — Hermes `cat >> ~/.hermes/config.yaml` corrupts existing
configs. Most existing hermes installs have a top-level gateway:
block; appending creates a duplicate, which YAML rejects. Replaced
the auto-append with explicit instructions: 'under your existing
gateway: block, add a plugin_platforms entry'.

Critical 2 — Codex `cat >> ~/.codex/config.toml` corrupts on
re-run. TOML rejects duplicate [mcp_servers.molecule] tables; a
second run breaks codex parse. Replaced auto-append with commented
config block + explicit 'open ~/.codex/config.toml in your editor
and paste'. Canvas-side token stamping still hits the literal in
the comment so the operator's clipboard has the real token already
substituted.

Required 3 — OpenClaw `onboard --non-interactive` missing
provider/model defaults. Added explicit --provider + --model
placeholders in a commented form so operators see what's needed
without a stub default applying silently.

Required 4 — OpenClaw gateway started with bare '&' dies on
terminal close. Switched to nohup + log file + disown, with a note
that systemd is the right answer for production.

Optional 5 + 6 (env_vars cleanup, tests) deferred — env_vars stripped
to keep the in-tree-vs-external surface narrow; tests for the new
response fields can land separately when external_connection.go is
next touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:12:54 -07:00
Hongming Wang 01bbf4c87b Merge pull request #2626 from Molecule-AI/ui/canvas-approvalbanner-polish
fix(canvas): ApprovalBanner Approve/Deny button polish
2026-05-03 20:12:13 +00:00
Hongming Wang e89dd892ac Merge pull request #2624 from Molecule-AI/fix/canvas-agentcomms-light-mode-prose
fix(canvas): AgentCommsPanel light-mode markdown contrast
2026-05-03 20:07:43 +00:00
Hongming Wang eba0c5e3f1 feat(canvas): add Hermes/Codex/OpenClaw tabs to ExternalConnectModal + default to Universal MCP
The External Connect modal had tabs for Python SDK / curl / Claude Code
channel / Universal MCP. Operators using hermes / codex / openclaw as
their external runtime had no copy-paste; they pieced together
WORKSPACE_ID + PLATFORM_URL + auth_token into config files by reading
docs.

Adds three runtime-specific snippets stamped server-side:

- **Hermes** — installs molecule-ai-workspace-runtime + the
  hermes-channel-molecule plugin, exports the 4 env vars, and writes
  the gateway.plugin_platforms.molecule block into ~/.hermes/config.yaml.
  Same long-poll-based push semantics the Claude Code channel tab
  delivers (push parity with the in-tree template-hermes adapter).

- **Codex** — wires the molecule_runtime A2A MCP server into
  ~/.codex/config.toml ([mcp_servers.molecule] block with env_vars
  passthrough + literal env values). Outbound tools only — codex's
  MCP client doesn't route arbitrary notifications/* (verified by
  reading codex-rs/codex-mcp/src/connection_manager.rs); push parity
  on external codex would need a separate bridge daemon, tracked
  as future work. Snippet calls this out so operators know to pair
  with Python SDK if they need inbound delivery.

- **OpenClaw** — installs openclaw + onboards, wires the molecule
  MCP server via openclaw mcp set, starts the gateway on loopback.
  Same outbound-tools-only caveat as codex; the in-tree template-
  openclaw adapter implements the full sessions.steer push path,
  but an external setup would need the same bridge daemon to translate
  platform inbox events into sessions.steer calls. Future work.

Default open tab changed from "Claude Code" to "Universal MCP".
Universal MCP is runtime-agnostic and works as a starting point for
any operator regardless of their downstream agent runtime; runtime-
specific tabs are still one click away. Pre-2026-05-03 the modal
defaulted to Claude Code, so operators using non-Claude runtimes
opened to a tab they had to skip past.

Tab order also reorganized:
  Universal MCP → Python SDK → Claude Code → Hermes → Codex → OpenClaw → curl → Fields

Each runtime-specific tab is gated on the platform supplying the
snippet (older platform builds without the field don't show empty
tabs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:07:19 -07:00
Hongming Wang c3ba5df9ff test(e2e): add canvas-terminal diagnose probe to synth-E2E (catches EIC-chain regressions in <20 min)
Why: the 2026-05-03 SG-missing-port-22 bug was structurally invisible to
local-dev — handleLocalConnect uses docker exec; only handleRemoteConnect
exercises EIC. The CP provisioner shipped without the EIC ingress rule
for ~6 months and nobody noticed until a paying tenant clicked Terminal.
Continuous synth-E2E runs every 20 min; adding this probe means the same
class of regression (CP provisioner ingress, EIC_ENDPOINT_SG_ID env,
handleRemoteConnect chain, SDK source-group support) surfaces within ~20
min of merge instead of waiting for a user report.

What: after Step 7 (workspace online), call
GET /workspaces/$wid/terminal/diagnose for each workspace. The endpoint
already exists in workspace-server (terminal_diagnose.go); it runs the
full EIC + ssh chain from inside the tenant (which has AWS creds via
its IAM profile) and returns {ok, first_failure, steps[]}. We just need
to call it as the tenant — no AWS creds plumbed onto the GHA runner,
no port-forwarding from CI.

Local-docker workspaces (instance_id NULL) hit diagnoseLocal which
probes docker.Ping + container exec; same ok=true contract, so the
probe works on both production paths.

This is a partial mitigation for task #269 (eliminate handleLocalConnect
bypass — local must mimic prod terminal path). The architectural fix
(refactor terminal.go so local docker also exercises an EIC-shaped
sequence) remains pending; this PR is the "find out issues earlier"
half of the user's directive.
2026-05-03 13:06:25 -07:00
Hongming Wang c37596fc26 fix(canvas): brighten agent chat prose body in dark mode
User feedback: chat-bubble agent text still washed out after #2618 +
#2623. Looked at the actual rendered colors and the issue was Tailwind
Typography's `prose-invert` defaults — body text ships at zinc-300,
which lands at ~5.3:1 against bg-zinc-700. Passes AA but visibly
duller than the user bubble's crisp white-on-blue (~10:1).

Override the prose CSS variables on the agent bubble in dark mode:
- body  → zinc-100  (was zinc-300)
- headings / bold → white
- inline code → zinc-100

That brings agent body text to ~13:1 against bg-zinc-700, matching the
user bubble's brightness so both sides of the conversation read at
the same crispness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:04:12 -07:00
Hongming Wang d2c202ddab fix(canvas): ApprovalBanner Approve/Deny button polish
Same bug class as #2622 (ConfirmDialog), but on a more critical surface
— this is the top-of-page banner asking the user to approve / deny a
real workspace permission request.

1. **Deny was a no-op hover.** `bg-surface-card hover:bg-surface-card`
   gave zero visual feedback before the user clicked a destructive
   action. Now lifts to surface-elevated + brightens the text so the
   button visibly responds.
2. **Approve hover went LIGHTER.** `bg-emerald-600 hover:bg-emerald-500`
   dropped white-text contrast on hover. Reversed to emerald-700.
3. **No focus rings on either button.** Keyboard users had no way to
   tell which decision was focused. Added focus-visible rings
   (offset against the dark amber banner bg) — emerald for Approve,
   amber for Deny so the choice is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:56:00 -07:00
Hongming Wang 79590eb861 Merge pull request #2621 from Molecule-AI/auto-sync/main-54f3c4d3
chore: sync main → staging (auto, ff to 54f3c4d3)
2026-05-03 19:52:09 +00:00
Hongming Wang 2d1a86cac9 fix(canvas): AgentCommsPanel light-mode markdown contrast
Discovered during code review of the #2623 hotfix audit. Same
regression class as #2618: prose-invert applied where the bubble bg
themes between light/dark, leaving markdown unreadable in one theme.

`MarkdownBody` was unconditionally `prose-invert` — fine for the
outgoing-message bubble (bg-cyan-900, dark in both themes) and the
failure bubble (bg-red-950, dark in both themes), but WRONG for the
incoming-message bubble (bg-surface-card, which themes LIGHT in light
mode). Result: light prose body text on light cream bg = invisible
markdown for incoming peer-to-peer messages in light mode.

Added an `invert: "always" | "dark-only"` prop to MarkdownBody. The
NormalMessage call sites switch on `msg.flow` so each bubble gets the
direction matching its bg's theming behavior. Failure bubble keeps
the default ("always") since red-950 stays dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:46:21 -07:00
Hongming Wang 954d2172f0 Merge pull request #2623 from Molecule-AI/fix/canvas-chat-agent-prose-invert
fix(canvas): agent chat bubble dark-mode prose contrast (regression #2618)
2026-05-03 19:42:49 +00:00
Hongming Wang 9fd52e9cd4 Merge pull request #2622 from Molecule-AI/ui/canvas-confirmdialog-polish
fix(canvas): ConfirmDialog hover + focus polish
2026-05-03 19:40:35 +00:00
Hongming Wang ffcffa1375 fix(canvas): agent chat bubble dark-mode prose contrast
Regression from PR #2618 (chat dark-contrast).

PR #2618 switched the agent bubble bg to `dark:bg-zinc-700` so it
visibly elevates against the dark panel — but the inner ReactMarkdown
prose div only got `prose-invert` for USER messages. Result: in dark
mode the agent's markdown text rendered with the Tailwind Typography
plugin's default dark body color on top of the new dark bg = invisible
text. User reported empty-looking gray rectangles where agent replies
should be.

Fix: apply `dark:prose-invert` to agent bubbles so prose body text
flips light alongside the bg. Light mode unchanged (default prose
colors against the warm `bg-surface-card`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:36:44 -07:00
Hongming Wang b5dea3c5df fix(canvas): ConfirmDialog hover + focus polish
Three issues on a high-stakes surface (revoke token, delete workspace,
cascade delete):

1. **Cancel hover was a no-op.** `bg-surface-card hover:bg-surface-card`
   gave zero visual feedback on hover. Now hovers to surface-elevated
   with a softened border so the button visibly lifts.

2. **Confirm hovers went LIGHTER, dropping white-text contrast.**
   `bg-red-600 hover:bg-red-500` made the destructive button less
   readable on hover. Same for warning (amber) and primary (accent).
   Reversed to hover-darker so contrast holds in both themes.

3. **No focus-visible rings on either button.** Keyboard users had no
   indication of focus position (WCAG 2.4.7 fail). Added
   `focus-visible:ring-2 focus-visible:ring-accent/40` on Cancel and
   `focus-visible:ring-2 focus-visible:ring-offset-2 ...accent/60` on
   Confirm so the focused destructive action is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:28:24 -07:00
molecule-ai[bot] 54f3c4d34f Merge pull request #2620 from Molecule-AI/staging
staging → main: auto-promote 5cd5a28
2026-05-03 12:22:19 -07:00
Hongming Wang 8d5e78d629 Merge pull request #2619 from Molecule-AI/test/synth-e2e-model-slug-coverage
test(e2e): pin pick_model_slug behavior with bash unit tests
2026-05-03 19:07:17 +00:00
Hongming Wang ac6f65ab5e test(e2e): pin pick_model_slug behavior with bash unit tests
PR #2571 fixed synth-E2E by branching MODEL_SLUG per runtime, but only
the langgraph branch was verified at runtime — hermes / claude-code /
override / fallback had zero automated coverage. A future regression
(e.g. dropping the langgraph case) would silently revert and only
surface as "Could not resolve authentication method" mid-E2E.

This PR:
- Extracts the dispatch into tests/e2e/lib/model_slug.sh as a sourceable
  pick_model_slug() function. No behavior change.
- Adds tests/e2e/test_model_slug.sh — 9 assertions across all 5 dispatch
  branches plus the override path. Verified to FAIL when any branch is
  flipped (manually regressed langgraph slash-form to confirm the test
  catches it; restored before commit).
- Wires the unit test into ci.yml's existing shellcheck job (only runs
  when tests/e2e/ or scripts/ change). Pure-bash, no live infra.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:04:12 -07:00
hongming 5cd5a28bd1 Merge pull request #2618 from Molecule-AI/ui/canvas-chat-dark-contrast
fix(canvas): dark-mode chat bubble contrast
2026-05-03 19:03:29 +00:00
Hongming Wang 026c81acf0 fix(canvas): dark-mode chat bubble contrast
User screenshot showed pale lavender user bubbles with hard-to-read white
text and a nearly-invisible agent bubble blending into the dark panel.

Root causes:
1. Tailwind v4 defaults `dark:` to `prefers-color-scheme: dark`. Our
   ThemeProvider writes `data-theme="dark"` on <html> so user toggle wins
   over OS — but `dark:` classes elsewhere in the codebase weren't
   tracking it. Added `@custom-variant dark` to re-bind the variant.
2. `bg-accent` themes lighter in dark mode (--color-accent: #6883e8),
   dropping white-text contrast to ~3:1 (fails WCAG AA). Switched user
   bubble to solid blue-600/500 so it stays ~5:1 in both modes.
3. `bg-surface-card` (#1a1d23) was only ~7% lighter than the panel bg
   (#0e1014), making agent bubbles disappear. Bumped to zinc-700 in
   dark; light mode keeps the warm surface-card tint.
4. System (error) bubble's /10 overlay was nearly invisible; raised to
   /25 in dark with stronger border + ink for readability.

Sub-tab + textarea polish included: low-contrast `text-ink-soft` →
`text-ink-mid`, focus-visible rings on tabs, dark variants on textarea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:00:51 -07:00
Hongming Wang a03045e5e4 Merge pull request #2617 from Molecule-AI/auto-sync/main-61223de3
chore: sync main → staging (auto, ff to 61223de3)
2026-05-03 18:53:12 +00:00
molecule-ai[bot] 61223de305 Merge pull request #2616 from Molecule-AI/staging
staging → main: auto-promote 4e90d3a
2026-05-03 11:48:27 -07:00
Hongming Wang 1355a1b539 Merge pull request #2615 from Molecule-AI/fix/2478-activity-logs-peer-indexes
feat(db): add per-peer btree indexes on activity_logs for chat_history scale (#2478)
2026-05-03 18:37:16 +00:00
Hongming Wang db132351a3 feat(db): add per-peer btree indexes on activity_logs for chat_history scale (#2478)
The chat_history query

  WHERE workspace_id = $1
    AND activity_type = 'a2a_receive'
    AND (source_id = $2 OR target_id = $2)
  ORDER BY created_at DESC

forces a workspace-scoped seq-scan-and-filter at every call —
idx_activity_ws_type_time covers workspace_id+type prefix but the
(source OR target) clause then walks every workspace row. Demo
workspaces (≤50 rows) don't notice; production workspaces accumulate
thousands over months and chat_history latency grows linearly.

Adds two partial btree indexes (workspace_id, source_id) WHERE NOT NULL
and (workspace_id, target_id) WHERE NOT NULL. Postgres BitmapOrs them
into a workspace-scoped BitmapAnd against the existing index, dropping
chat_history from O(workspace_rows) to O(peer_a2a_rows).

Partial WHERE NOT NULL because most activity rows (heartbeats,
agent_log, memory_write, etc.) carry NULL source_id/target_id and
shouldn't bloat the index.

Anti-pattern caveat (per the issue): a single compound (a, b) index
can't serve 'a OR b' — Postgres only uses compound for prefix match.
Two separate indexes + BitmapOr is the right shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:34:35 -07:00
Hongming Wang 4e90d3a32d Merge pull request #2614 from Molecule-AI/ui/canvas-toolbar-contrast
fix(canvas): Toolbar contrast + focus rings
2026-05-03 18:29:04 +00:00
Hongming Wang e1d635a099 fix(canvas): Toolbar contrast + focus rings
Top-of-canvas Toolbar had multiple low-contrast surfaces in light theme:

Action buttons (Stop All, Restart Pending):
- bg-red-950/50 + bg-amber-950/40 → bg-bad/10 + bg-warm/10 with bg-bad/40
  + bg-warm/40 borders. Dark-tinted backgrounds with /40-/50 alpha render
  as nearly invisible smudges on warm-paper; semantic tokens at /10 give
  a clear pale-bad / pale-warm tint that scales correctly in dark mode.
- Both gain focus-visible:ring-2 focus-visible:ring-{bad,warm}/40.

Toggle button (A2A edges):
- Active state: bg-blue-950/50 → bg-accent/15 (themes correctly).
- Inactive state: bg-surface-card/50 + text-ink-soft → solid bg-surface-card
  + text-ink-mid; hover bumps to text-ink. Drops the redundant
  "hover:bg-surface-card/50" identity hover.

Icon buttons (Audit, Search, Help):
- Same pattern as toggle inactive: solid bg-surface-card + text-ink-mid +
  text-ink hover, with focus-visible:ring-2 focus-visible:ring-accent/40.

Workspace count + bullet separator:
- text-ink-soft (3.5:1 on warm-paper) → text-ink-mid (7:1).

WS connection status:
- "Live": text-ink-soft → text-ink-mid (paired with the green dot).
- "Reconnecting": text-ink-soft → text-warm (semantic match for amber dot).
- "Offline": text-ink-soft → text-bad (semantic match for red dot).
  Status text now reinforces the dot colour instead of disappearing on
  light surfaces.

Help popover:
- Close button: text-ink-soft → text-ink-mid + focus-visible:underline.
- HelpRow body text: text-ink-soft → text-ink-mid (was 3.5:1 on the
  bg-surface-sunken/45 popover row — failed AA for body text).
2026-05-03 11:26:28 -07:00
Hongming Wang 80c6f6e4b6 Merge pull request #2613 from Molecule-AI/auto-sync/main-a1e40fe0
chore: sync main → staging (auto, ff to a1e40fe0)
2026-05-03 18:23:13 +00:00
55 changed files with 2221 additions and 386 deletions
+26 -2
View File
@@ -231,10 +231,34 @@ jobs:
and o.get('status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug DELETE with HTTP-code verification. The previous
# `... >/dev/null || true` swallowed every failure, so a 5xx
# or timeout from CP looked identical to "successfully cleaned
# up" and the tenant kept eating ~2 vCPU until the hourly
# stale sweep caught it (up to 2h later). Now we capture the
# response code and surface non-2xx as a workflow warning, so
# the run page shows which slug leaked. We still don't `exit 1`
# on cleanup failure — a single-canary cleanup miss shouldn't
# fail-flag the canary itself when the actual smoke check
# passed. The sweep-stale-e2e-orgs cron (now every 15 min,
# 30-min threshold) is the safety net for whatever slips past.
# See molecule-controlplane#420.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/canary-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::canary teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canary-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::canary teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+12
View File
@@ -272,6 +272,18 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: needs.changes.outputs.scripts == 'true'
name: Run E2E bash unit tests (no live infra)
# Pure-bash unit tests for E2E helper libs (lib/*.sh). These pin
# behavior of dispatch logic that — when broken — silently masks as
# "Could not resolve authentication method" only after a successful
# tenant + workspace provision (PR #2571 incident, 2026-05-03). Add
# new self-contained unit tests here as the lib/ directory grows;
# tests requiring live CP/tenant credentials belong in the dedicated
# e2e-staging-* workflows, not this job.
run: |
bash tests/e2e/test_model_slug.sh
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
+87 -33
View File
@@ -32,16 +32,31 @@ name: Continuous synthetic E2E (staging)
on:
schedule:
# Every 20 minutes, on the :00 :20 :40. Offsets the existing :15
# sweep-cf-orphans and :45 sweep-cf-tunnels so the three
# operations don't all hit Cloudflare/AWS at the same minute.
- cron: '0,20,40 * * * *'
# Every 20 minutes, on :10 :30 :50. Two constraints:
# 1. Stay off the top-of-hour. GitHub Actions scheduler drops
# :00 firings under high load (own docs:
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule).
# Empirical 2026-05-03: cron was '0,20,40 * * * *' but actual
# firings landed at :08, :03, :01, :03 with :20 + :40 silently
# dropped — only the :00-region run survived. Detection
# latency degraded from claimed 20 min to actual ~60 min.
# :10/:30/:50 sit far enough from :00 that GH-load skips
# stop dropping us.
# 2. Avoid colliding with the existing :15 sweep-cf-orphans
# and :45 sweep-cf-tunnels — both hit the CF API and we
# don't want to fight for rate-limit tokens.
- cron: '10,30,50 * * * *'
workflow_dispatch:
inputs:
runtime:
description: "Runtime to provision (langgraph = fastest, default; hermes = slower but covers SDK-native path; claude-code = needs OAUTH token in tenant env)"
description: "Runtime to provision (claude-code = default + cheapest via MiniMax; langgraph = OpenAI-only; hermes = SDK-native path, slower)"
required: false
default: "langgraph"
default: "claude-code"
type: string
model_slug:
description: "Model id to provision the workspace with (default MiniMax-M2.7-highspeed; e.g. 'sonnet' to test direct Anthropic, 'openai/gpt-4o' for hermes)"
required: false
default: "MiniMax-M2.7-highspeed"
type: string
keep_org:
description: "Skip teardown for post-mortem debugging (only manual dispatch — never set this for cron runs)"
@@ -70,13 +85,23 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
env:
# langgraph default keeps cold-start under 5 min on staging EC2.
# hermes is slower (~7-10 min) and isn't needed for the
# regression class this gate exists to catch (deployment-pipeline
# + schema-drift + integration). Operators can pick hermes via
# workflow_dispatch when they need to exercise the SDK-native
# session path.
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'langgraph' }}
# claude-code default: cold-start ~5 min (comparable to langgraph),
# but uses MiniMax-M2.7-highspeed via the template's third-party-
# Anthropic-compat path (workspace-configs-templates/claude-code-
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
# exhaustion class that took the canary down 2026-05-03 (#265).
# Operators can pick langgraph / hermes via workflow_dispatch
# when they specifically need to exercise the OpenAI or SDK-
# native paths.
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
# Pin the canary to a specific MiniMax model rather than relying
# on the per-runtime default ("sonnet" → routes to direct
# Anthropic, defeats the cost saving). Operators can override
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
# input if they need to exercise a specific model. M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
# Bound to 10 min so a stuck provision fails the run instead of
# holding up the next cron firing. 15-min default in the script
# is for the on-PR full lifecycle where we have more headroom.
@@ -88,37 +113,66 @@ jobs:
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
# Provisioned tenant's default model (langgraph: openai:gpt-4.1-mini)
# needs OPENAI_API_KEY at first call. Sibling workflows
# e2e-staging-saas.yml + canary-staging.yml use the same secret;
# without this wire-up the tenant boots, accepts a2a messages,
# then returns "Could not resolve authentication method" — masked
# earlier by the a2a-sdk task-mode contract bugs PR #2558+#2563
# fixed. tests/e2e/test_staging_full_saas.sh:325 reads this and
# persists it as a workspace_secret on tenant create.
# MiniMax key is the canary's PRIMARY auth path. claude-code
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
# tests/e2e/test_staging_full_saas.sh branches SECRETS_JSON on
# which key is present — MiniMax wins when set.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
# OpenAI fallback — kept wired so operators can dispatch with
# E2E_RUNTIME=langgraph or =hermes and still have a working
# canary path. The script picks the right blob shape based on
# which key is non-empty.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secret present
- name: Verify required secrets present
run: |
# Schedule-vs-dispatch hardening (mirrors the sweep-cf-* and
# redeploy-tenants-on-* workflows): hard-fail on missing secret
# for cron firing so a misconfigured-repo doesn't silently
# report green while doing nothing. Soft-skip on operator
# dispatch — operators can dispatch ad-hoc to verify a fix
# without setting up the secret first.
# Hard-fail on missing secret REGARDLESS of trigger. Previously
# this step soft-skipped on workflow_dispatch via `exit 0`, but
# `exit 0` only ends the STEP — subsequent steps still ran with
# the empty secret, the synth script fell through to the wrong
# SECRETS_JSON branch, and the canary failed 5 min later with a
# confusing "Agent error (Exception)" instead of the clean
# "secret missing" message at the top. Caught 2026-05-04 by
# dispatched run 25296530706: claude-code + missing MINIMAX
# silently used OpenAI keys but kept model=MiniMax-M2.7, then
# the workspace 401'd against MiniMax once it tried to call.
# Fix: exit 1 in both cron and dispatch paths. Operators who
# want to verify a YAML change without setting up the secret
# can read the verify-secrets step's stderr — the failure is
# itself the verification signal.
if [ -z "${MOLECULE_ADMIN_TOKEN:-}" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::CP_STAGING_ADMIN_API_TOKEN not set — synth E2E cannot run"
echo "::warning::Set it at Settings → Secrets and Variables → Actions"
exit 0
fi
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret missing — synth E2E cannot run"
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 1
fi
# LLM-key requirement is per-runtime: claude-code uses MiniMax
# (MOLECULE_STAGING_MINIMAX_API_KEY), langgraph + hermes use
# OpenAI (MOLECULE_STAGING_OPENAI_KEY).
case "${E2E_RUNTIME}" in
claude-code)
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
required_secret_value="${E2E_MINIMAX_API_KEY:-}"
;;
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
*)
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
required_secret_name=""
required_secret_value="present"
;;
esac
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
echo "::error::${required_secret_name} secret missing — runtime=${E2E_RUNTIME} cannot authenticate against its LLM provider"
echo "::error::Set it at Settings → Secrets and Variables → Actions, OR dispatch with a different runtime"
exit 1
fi
- name: Install required tools
run: |
# The script depends on jq + curl (already on ubuntu-latest)
+17 -2
View File
@@ -184,8 +184,23 @@ jobs:
exit 0
fi
echo "Deleting orphan tenant: $slug"
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
# Verify HTTP 2xx instead of `>/dev/null || true` swallowing
# failures. A 5xx or timeout previously looked identical to
# success, leaving the tenant alive for up to ~45 min until
# sweep-stale-e2e-orgs caught it. Surface failures as
# workflow warnings naming the slug. Don't `exit 1` — a single
# cleanup miss shouldn't fail-flag the canvas test when the
# actual smoke check passed; the sweeper is the safety net.
# See molecule-controlplane#420.
code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
fi
exit 0
+18 -2
View File
@@ -153,12 +153,28 @@ jobs:
if [ -n "$orgs" ]; then
echo "Safety-net sweep: deleting leftover orgs:"
echo "$orgs"
# Per-slug verified DELETE — see molecule-controlplane#420.
# `>/dev/null 2>&1` previously hid every failure; surface
# non-2xx as workflow warnings so the run page names what
# leaked. Sweeper catches the rest within ~45 min.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null 2>&1
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::external teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/external-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::external teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
else
echo "Safety-net sweep: no leftover orgs to clean."
fi
+18 -2
View File
@@ -164,11 +164,27 @@ jobs:
and o.get('instance_status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug verified DELETE (was `>/dev/null || true` — see
# molecule-controlplane#420). Surface non-2xx as a workflow
# warning naming the leaked slug; don't exit 1 (sweeper is
# the safety net within ~45 min).
leaks=()
for slug in $orgs; do
echo "Safety-net teardown: $slug"
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::saas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/saas-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::saas teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+17 -2
View File
@@ -143,10 +143,25 @@ jobs:
and o.get('status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug verified DELETE — see molecule-controlplane#420.
# Failures surface as workflow warnings; the sweeper is the
# safety net within ~45 min.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::sanity teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/sanity-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::sanity teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+14 -7
View File
@@ -25,16 +25,23 @@ name: Sweep stale e2e-* orgs (staging)
on:
schedule:
# Every hour on the hour. E2E orgs are short-lived (~10-25 min wall
# clock from create to teardown). Anything older than the
# MAX_AGE_MINUTES threshold below is presumed dead.
- cron: '0 * * * *'
# Every 15 min. E2E orgs are short-lived (~8-25 min wall clock from
# create to teardown — canary is ~8 min, full SaaS ~25 min). The
# previous hourly + 120-min stale threshold meant a leaked tenant
# could keep an EC2 alive for up to 2 hours, eating ~2 vCPU per
# leak. Tightening the cadence + threshold reduces the worst-case
# leak window from 120 min to ~45 min (15-min sweep cadence + 30-min
# threshold) without risk of catching in-progress runs (the longest
# e2e run is the 25-min canary, well under the 30-min threshold).
# See molecule-controlplane#420 for the leak-class accounting that
# motivated this tightening.
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
max_age_minutes:
description: "Delete e2e-* orgs older than N minutes (default 120)"
description: "Delete e2e-* orgs older than N minutes (default 30)"
required: false
default: "120"
default: "30"
dry_run:
description: "Dry run only — list what would be deleted"
required: false
@@ -58,7 +65,7 @@ jobs:
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '120' }}
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '30' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
# Refuse to delete more than this many orgs in one tick. If the
# CP DB is briefly empty (or the admin endpoint goes weird and
+22 -2
View File
@@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
orgID = row.id;
return true;
}
if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`);
if (row.instance_status === "failed") {
// Dump every diagnostic field the admin row carries — boot stage,
// last error, terraform/SSM state, etc. The bare slug message used
// to surface ZERO context, so triaging a failed provision meant
// re-running locally to repro. Now the failure log carries enough
// to point at the right subsystem (CP/AWS/SSM/runtime) without a
// second round-trip.
throw new Error(
`provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`,
);
}
return null;
},
PROVISION_TIMEOUT_MS,
@@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
if (r.status !== 200) return null;
if (r.body?.status === "online") return true;
if (r.body?.status === "failed") {
throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`);
// last_sample_error is often empty when the failure happens before
// the agent emits a sample (e.g. boot crash, image pull error,
// missing PYTHONPATH, OpenAI quota at startup). Dumping the full
// body gives triage the boot_stage / last_error / image fields it
// needs without a second probe. Otherwise this propagates as a
// bare "Workspace failed: " — the exact useless message that
// sent #2632 to the issue tracker.
const detail = r.body.last_sample_error
? r.body.last_sample_error
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
throw new Error(`Workspace failed: ${detail}`);
}
return null;
},
+9
View File
@@ -1,6 +1,15 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/*
* Tailwind v4 defaults the `dark:` variant to `prefers-color-scheme: dark`.
* Our theme switcher writes `data-theme="dark"` on <html> instead (so user
* choice via the toggle wins over OS preference). Re-bind `dark:` to that
* attribute so component classes like `dark:bg-zinc-800` track the same
* source of truth as the `[data-theme="dark"]` token overrides below.
*/
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/*
* Load order:
* 1. Tailwind core (v4) — provides preflight + utility generation.
+7 -2
View File
@@ -73,14 +73,19 @@ export function ApprovalBanner() {
<button
type="button"
onClick={() => handleDecide(approval, "approved")}
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
// Hover DARKER not lighter — emerald-500 on white text
// drops contrast vs emerald-700.
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
>
Approve
</button>
<button
type="button"
onClick={() => handleDecide(approval, "denied")}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded-lg text-ink-mid transition-colors"
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
// Lift to surface-elevated on hover so the button visibly
// responds before a destructive deny.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
>
Deny
</button>
+19 -1
View File
@@ -30,6 +30,24 @@ export function BatchActionBar() {
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
}, [count, hasFailedBatch]);
// Esc clears selection — the deselect button title has been promising
// "(Escape)" since the bar shipped, but no handler was wired. Skip when
// the confirm dialog is open (`pending !== null`) so the dialog's own
// Esc-cancels takes precedence and we don't double-handle the keystroke.
// Also skip during a busy in-flight action so the user can't accidentally
// strand a partial-failure mid-flight.
useEffect(() => {
if (count === 0 || pending !== null || busy) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
clearSelection();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [count, pending, busy, clearSelection]);
// Hide when nothing is selected. Hide for single-node selection UNLESS a
// partial-failure left a survivor awaiting retry.
if (count === 0) return null;
@@ -129,7 +147,7 @@ export function BatchActionBar() {
onClick={clearSelection}
aria-label="Clear selection"
title="Clear selection (Escape)"
className="p-1.5 rounded-lg text-[12px] text-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"
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
</button>
+18 -5
View File
@@ -117,9 +117,11 @@ export function BundleDropZone() {
📦 Import bundle
</button>
{/* Visual overlay when dragging */}
{/* Visual overlay when dragging — was hardcoded blue-950/blue-400
which doesn't flip with theme. accent colors stay visually
consistent with the rest of the canvas in both modes. */}
{isDragging && (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
<div className="fixed inset-0 z-20 flex items-center justify-center bg-accent/15 backdrop-blur-sm border-2 border-dashed border-accent/40 pointer-events-none">
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
@@ -128,10 +130,21 @@ export function BundleDropZone() {
</div>
)}
{/* Importing spinner */}
{/* Importing indicator — role=status + aria-live so SR users hear
"Importing bundle..." while the API call is in flight, not just
the result toast that fires after. motion-safe:animate-spin
respects prefers-reduced-motion (Tailwind's motion-safe variant
gates animation on the user's OS setting). */}
{importing && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-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" />
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-surface-sunken/95 border border-line/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3"
>
<div
aria-hidden="true"
className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full motion-safe:animate-spin"
/>
<span className="text-sm text-ink">Importing bundle...</span>
</div>
)}
+8 -5
View File
@@ -91,12 +91,15 @@ export function ConfirmDialog({
if (!open || !mounted) return null;
// Hover goes DARKER, not lighter — lighter shades on white text drop
// contrast below AA on the accent and red ramps. Darker hovers stay
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-600 hover:bg-red-500 text-white"
? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning"
? "bg-amber-600 hover:bg-amber-500 text-white"
: "bg-accent-strong hover:bg-accent text-white";
? "bg-amber-600 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
// Render via Portal so the fixed-position dialog escapes any containing block
// (e.g. parents with transform, filter, will-change that break position:fixed).
@@ -123,7 +126,7 @@ export function ConfirmDialog({
<button
type="button"
onClick={onCancel}
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"
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Cancel
</button>
@@ -131,7 +134,7 @@ export function ConfirmDialog({
<button
type="button"
onClick={onConfirm}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
>
{confirmLabel}
</button>
+17 -4
View File
@@ -113,7 +113,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
className="text-ink-mid hover:text-ink text-sm px-2"
// 24x24 touch target (was ~10x16, well under WCAG 2.5.5).
// Hover bg makes the area visible; focus-visible ring matches
// the rest of the canvas chrome.
className="w-6 h-6 inline-flex items-center justify-center rounded text-sm text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
</button>
@@ -150,12 +153,19 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(output);
// Add success feedback — without it, clicking Copy
// looked like a no-op since the previous hover bg was
// also a no-op (`hover:bg-surface-card` on top of the
// same base). Toast confirms the write actually fired.
navigator.clipboard
.writeText(output)
.then(() => showToast("Console output copied", "success"))
.catch(() => showToast("Copy failed", "error"));
} else {
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Copy
</button>
@@ -163,7 +173,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-[11px] text-ink-mid bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
// Was hover:bg-surface-card (same as base — silent no-op).
// Lift to surface-elevated so the button visibly responds,
// matching the Cancel button in ConfirmDialog.
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Close
</button>
+29 -6
View File
@@ -29,15 +29,38 @@ export function ContextMenu() {
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const ref = useRef<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false);
// Clamped position — (left, top) from contextMenu may overflow when the
// user right-clicks near the right/bottom viewport edge. We measure the
// rendered menu and shift it back inside on the same frame the cursor
// opens it, so it never visibly clips. Falls back to the raw cursor
// coords until the rAF runs.
const [clamped, setClamped] = useState<{ x: number; y: number } | null>(null);
// Auto-focus first enabled item when menu opens
// Auto-focus first enabled item when menu opens, AND clamp position.
// Both run together in a single rAF so we avoid two synchronous layout
// reads + a paint between them.
useEffect(() => {
if (!contextMenu) return;
requestAnimationFrame(() => {
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
setClamped(null);
const raf = requestAnimationFrame(() => {
const node = ref.current;
if (!node) return;
const first = node.querySelector<HTMLButtonElement>("button:not(:disabled)");
first?.focus();
// 8px viewport margin so the menu doesn't kiss the edge — matches
// the floating-tooltip top-edge clamp in Tooltip.tsx.
const margin = 8;
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = contextMenu.x;
let y = contextMenu.y;
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
if (x !== contextMenu.x || y !== contextMenu.y) setClamped({ x, y });
});
}, [contextMenu?.nodeId]);
return () => cancelAnimationFrame(raf);
}, [contextMenu?.nodeId, contextMenu?.x, contextMenu?.y]);
// Close on click outside or Escape
useEffect(() => {
@@ -288,7 +311,7 @@ export function ContextMenu() {
aria-label={`Actions for ${contextMenu.nodeData.name}`}
onKeyDown={handleMenuKeyDown}
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 }}
style={{ left: clamped?.x ?? contextMenu.x, top: clamped?.y ?? contextMenu.y }}
>
{/* Header */}
<div className="px-3.5 py-2 border-b border-line/40 mb-0.5">
@@ -314,7 +337,7 @@ export function ContextMenu() {
onClick={item.action}
disabled={item.disabled}
aria-disabled={item.disabled}
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent/50 disabled:opacity-25 disabled:cursor-not-allowed ${
item.danger
? "text-bad hover:bg-red-950/40 hover:text-bad"
: "text-ink-mid hover:bg-surface-card/40 hover:text-ink"
+15 -7
View File
@@ -98,9 +98,17 @@ export function CookieConsent() {
};
return (
<div
role="dialog"
aria-modal="true"
// role="region" + aria-label, NOT role="dialog" + aria-modal. The
// banner is informational — it never blocks the page, never traps
// focus, and the user can keep using the canvas while it's up.
// Claiming aria-modal="true" without a focus trap is genuinely
// harmful for screen-reader users: they get told the rest of the
// page is inert, jump into the banner, and then can't escape.
// Region semantics let assistive tech navigate around it normally.
// (Also: forcing a modal cookie banner would be a dark pattern —
// GDPR explicitly discourages it.)
<section
role="region"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-body"
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-line bg-surface/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
@@ -117,7 +125,7 @@ export function CookieConsent() {
workspaces). See our{" "}
<a
href="https://moleculesai.app/legal/privacy"
className="text-accent underline hover:text-accent"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
@@ -130,20 +138,20 @@ export function CookieConsent() {
<button
type="button"
onClick={() => decide("rejected")}
className="rounded border border-line bg-surface-sunken px-4 py-2 text-sm text-ink hover:bg-surface-card"
className="rounded border border-line bg-surface-sunken px-4 py-2 text-sm text-ink hover:bg-surface-card focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Necessary only
</button>
<button
type="button"
onClick={() => decide("accepted")}
className="rounded border border-accent bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent"
className="rounded border border-accent bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Accept all
</button>
</div>
</div>
</div>
</section>
);
}
@@ -310,7 +310,7 @@ export function CreateWorkspaceButton() {
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-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">
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-accent hover:bg-accent-strong active:bg-accent text-sm font-medium rounded-xl text-white shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/30 transition-all duration-200 flex items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface">
<svg
width="14"
height="14"
@@ -502,7 +502,7 @@ export function CreateWorkspaceButton() {
placeholder="sk-…"
aria-label="Hermes API key"
autoComplete="off"
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"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
</div>
@@ -527,7 +527,7 @@ export function CreateWorkspaceButton() {
autoComplete="off"
spellCheck={false}
list="hermes-model-suggestions"
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"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
<datalist id="hermes-model-suggestions">
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
@@ -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-surface-card hover:bg-surface-card text-sm rounded-lg text-ink-mid transition-colors">
<button type="button" className="px-4 py-2 bg-surface-card hover:bg-surface-elevated hover:text-ink text-sm rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
Cancel
</button>
</Dialog.Close>
@@ -560,7 +560,7 @@ export function CreateWorkspaceButton() {
type="button"
onClick={handleCreate}
disabled={creating}
className="px-5 py-2 bg-accent-strong hover:bg-accent active:bg-accent-strong text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
className="px-5 py-2 bg-accent hover:bg-accent-strong active:bg-accent text-sm rounded-lg text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{creating ? "Creating..." : "Create"}
</button>
@@ -623,7 +623,7 @@ function InputField({
placeholder={placeholder}
min={type === "number" ? "0" : undefined}
step={type === "number" ? "0.01" : undefined}
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" : ""}`}
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
/>
{helper && (
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
@@ -127,13 +127,16 @@ export function DeleteCascadeConfirmDialog({
</p>
</div>
{/* Checkbox guard */}
{/* Checkbox guard. Ring-offset color was zinc-900 — the dialog
actually sits on bg-surface-sunken, so the offset showed
the wrong color through the ring gap. Switched to the
real bg + a danger-tinted ring. */}
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
/>
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
I understand this will permanently delete all listed workspaces and their data
@@ -145,7 +148,11 @@ export function DeleteCascadeConfirmDialog({
<button
type="button"
onClick={onCancel}
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"
// Was hover:bg-surface-card (same as base — silent no-op).
// Lift to surface-elevated to match the Cancel pattern in
// ConfirmDialog. Added focus-visible ring so keyboard users
// see where focus lands.
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Cancel
</button>
@@ -153,9 +160,12 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>
+305 -8
View File
@@ -18,6 +18,157 @@
import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
// Per-tab help metadata: docs link, where-to-install link, common errors.
// All URLs verified against repo content (docs/guides/* file paths map to
// docs.molecule.ai/docs/guides/*; canonical hostname confirmed by existing
// blog post canonical metadata) or against the snippet text the operator
// just copied. Never linking to a URL that wasn't already in product —
// dead links here defeat the purpose of "more comprehensive instructions."
const TAB_HELP: Record<
Tab,
{
docsUrl?: string;
docsLabel?: string;
downloadUrl?: string;
downloadLabel?: string;
commonIssues?: { symptom: string; check: string }[];
}
> = {
mcp: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "Tools not appearing in your agent",
check:
"Run `claude mcp list` (or your runtime's equivalent) — the molecule entry should be listed. If missing, re-run the `claude mcp add` line.",
},
{
symptom: "ConnectionRefused / DNS error on first call",
check:
"PLATFORM_URL must include the scheme (https://) and have no trailing slash. Verify with `curl $PLATFORM_URL/healthz`.",
},
],
},
python: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "401 from /heartbeat",
check:
"AUTH_TOKEN expired or wrong workspace_id. Tokens are shown only once at create time — re-create the workspace to get a fresh token.",
},
{
symptom: "AGENT_URL not reachable from platform",
check:
"Public HTTPS URL required for inbound A2A. Use ngrok or Cloudflare Tunnel if your agent is behind NAT.",
},
],
},
claude: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://claude.com/claude-code",
downloadLabel: "Claude Code (claude.com)",
commonIssues: [
{
symptom: "plugin not installed",
check:
"Run `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` then `/plugin install molecule@molecule-mcp-claude-channel` inside Claude Code, then `/reload-plugins`.",
},
{
symptom: "not on the approved channels allowlist",
check:
"Custom channels need `--dangerously-load-development-channels` on the launch command. Team/Enterprise orgs need admin to set `channelsEnabled` + `allowedChannelPlugins` in claude.ai admin settings.",
},
{
symptom: "Inbound messages not arriving",
check:
"Check stderr for `molecule channel: connected — watching N workspace(s)`. Verify ~/.claude/channels/molecule/.env has the right PLATFORM_URL + token.",
},
],
},
hermes: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://github.com/NousResearch/hermes-agent",
downloadLabel: "hermes-agent (NousResearch)",
commonIssues: [
{
symptom: "Gateway start failure",
check:
"Tail ~/.hermes/gateway.log. YAML duplicate-key in config.yaml is the most common cause — `gateway:` block must appear exactly once.",
},
{
symptom: "Plugin not discovered after install",
check:
"Run `pip show hermes-channel-molecule` to confirm install. Some hermes builds need `hermes plugin reload` before the new platform_plugins entry takes effect.",
},
],
},
codex: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://github.com/openai/codex",
downloadLabel: "openai/codex",
commonIssues: [
{
symptom: "[mcp_servers.molecule] not loaded",
check:
"Codex must be ≥ 0.57. Check with `codex --version`; upgrade via `npm install -g @openai/codex@latest`.",
},
{
symptom: "TOML parse error after re-running setup",
check:
"TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.",
},
],
},
openclaw: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
commonIssues: [
{
symptom: "Gateway not starting",
check:
"Tail ~/.openclaw/gateway.log. The loopback bind requires :18789 to be free — check with `lsof -iTCP:18789`.",
},
{
symptom: "openclaw mcp set rejected",
check:
"The heredoc generates JSON; verify it parsed by running `jq < ~/.openclaw/mcp/molecule.json`. Re-run `openclaw mcp set` if the file is malformed.",
},
],
},
curl: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
commonIssues: [
{
symptom: "401 / 403 on register",
check:
"WORKSPACE_AUTH_TOKEN must be the value shown at workspace create. Tokens are shown only once.",
},
],
},
fields: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
},
};
export interface ExternalConnectionInfo {
workspace_id: string;
platform_url: string;
@@ -40,6 +191,22 @@ export interface ExternalConnectionInfo {
// + inbound. Optional for backward compat with platforms that
// haven't shipped PR #2413 yet.
universal_mcp_snippet?: string;
// Hermes channel snippet — for operators whose external agent IS a
// hermes-agent session. Routes A2A traffic into the hermes gateway
// via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule).
// Long-poll based (no tunnel) — same UX shape as the Claude Code
// channel tab. Gives hermes true push parity. Optional for backward
// compat with platforms that haven't shipped this PR yet.
hermes_channel_snippet?: string;
// Codex MCP config snippet — wires the molecule MCP server into
// ~/.codex/config.toml so codex agents can call platform tools.
// Outbound-tools-only today (codex's MCP client doesn't route
// notifications/*); push parity would need a separate bridge daemon.
codex_snippet?: string;
// OpenClaw MCP config snippet — wires molecule MCP + starts the
// openclaw gateway on loopback. Outbound-tools-only today; push
// parity on an external openclaw needs a sessions.steer bridge.
openclaw_snippet?: string;
}
interface Props {
@@ -47,13 +214,19 @@ interface Props {
onClose: () => void;
}
type Tab = "python" | "curl" | "claude" | "mcp" | "fields";
export function ExternalConnectModal({ info, onClose }: Props) {
// Default to Claude Code when the platform offers it — that's the
// newest + simplest path (no tunnel needed). Falls back to Python
// for older platform builds that don't ship the snippet.
const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python";
// Default to Universal MCP when the platform offers it — runtime-
// agnostic outbound tool path that works for any MCP-aware runtime
// (Claude Code, hermes, codex, etc.) and lets operators inspect the
// primitives before picking a runtime-specific tab. Python SDK is
// the fallback for platforms predating the universal_mcp_snippet
// field. Pre-2026-05-03 the default was "claude" (Claude Code first)
// but operators using non-Claude runtimes opened to a tab they had
// to skip past — universal MCP works for everyone as a starting
// point and the runtime-specific tabs are still one click away.
const initialTab: Tab = info?.universal_mcp_snippet
? "mcp"
: "python";
const [tab, setTab] = useState<Tab>(initialTab);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
@@ -108,6 +281,24 @@ export function ExternalConnectModal({ info, onClose }: Props) {
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
);
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
// name as Universal MCP). Stamp the auth_token in so the operator's
// copy-paste is fully ready-to-run.
const filledHermes = info.hermes_channel_snippet?.replace(
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
);
// Codex + OpenClaw snippets carry the placeholder inside the
// generated config block (TOML / JSON respectively). Stamp the
// token in so the copy-paste is one less manual edit.
const filledCodex = info.codex_snippet?.replace(
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
);
const filledOpenClaw = info.openclaw_snippet?.replace(
'WORKSPACE_TOKEN="<paste from create response>"',
`WORKSPACE_TOKEN="${info.auth_token}"`,
);
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
@@ -135,10 +326,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
// SDK second (full register+heartbeat+inbound); Universal
// MCP third (any MCP-aware runtime, outbound-only); curl
// for one-shot register; Fields for raw values.
// Tab order: Universal MCP first (default, runtime-
// agnostic primitives), then runtime-specific channel/
// SDK tabs, then curl + Fields. Each runtime tab only
// appears when the platform supplies the snippet — no
// dead "tab missing snippet" UX.
const tabs: Tab[] = [];
if (filledChannel) tabs.push("claude");
tabs.push("python");
if (filledUniversalMcp) tabs.push("mcp");
tabs.push("python");
if (filledChannel) tabs.push("claude");
if (filledHermes) tabs.push("hermes");
if (filledCodex) tabs.push("codex");
if (filledOpenClaw) tabs.push("openclaw");
tabs.push("curl", "fields");
return tabs;
})().map((t) => (
@@ -156,6 +355,12 @@ export function ExternalConnectModal({ info, onClose }: Props) {
>
{t === "claude"
? "Claude Code"
: t === "hermes"
? "Hermes"
: t === "codex"
? "Codex"
: t === "openclaw"
? "OpenClaw"
: t === "python"
? "Python SDK"
: t === "mcp"
@@ -205,6 +410,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
onCopy={() => copy(filledUniversalMcp, "mcp")}
/>
)}
{tab === "hermes" && filledHermes && (
<SnippetBlock
value={filledHermes}
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
copyKey="hermes"
copied={copiedKey === "hermes"}
onCopy={() => copy(filledHermes, "hermes")}
/>
)}
{tab === "codex" && filledCodex && (
<SnippetBlock
value={filledCodex}
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
copyKey="codex"
copied={copiedKey === "codex"}
onCopy={() => copy(filledCodex, "codex")}
/>
)}
{tab === "openclaw" && filledOpenClaw && (
<SnippetBlock
value={filledOpenClaw}
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
copyKey="openclaw"
copied={copiedKey === "openclaw"}
onCopy={() => copy(filledOpenClaw, "openclaw")}
/>
)}
{tab === "fields" && (
<div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
@@ -220,6 +452,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
<HelpBlock help={TAB_HELP[tab]} />
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -268,6 +501,70 @@ function SnippetBlock({
);
}
// HelpBlock — collapsible "Need help?" section under each tab's snippet.
// Renders only the keys present in the per-tab help metadata (no empty
// sections). Closed by default so the snippet stays the visual focus;
// operators with a working setup never see this. Uses native <details>
// for keyboard accessibility (Tab + Enter) without extra ARIA wiring.
function HelpBlock({
help,
}: {
help: (typeof TAB_HELP)[Tab] | undefined;
}) {
if (!help) return null;
const { docsUrl, docsLabel, downloadUrl, downloadLabel, commonIssues } = help;
if (!docsUrl && !downloadUrl && !commonIssues?.length) return null;
return (
<details className="mt-3 border border-line rounded-lg bg-surface text-xs">
<summary className="cursor-pointer select-none px-3 py-2 text-ink-mid hover:text-ink">
Need help? install link, docs, common errors
</summary>
<div className="px-3 pb-3 pt-1 space-y-2">
{downloadUrl && (
<div>
<span className="text-ink-soft">Where to install: </span>
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{downloadLabel || downloadUrl}
</a>
</div>
)}
{docsUrl && (
<div>
<span className="text-ink-soft">Documentation: </span>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{docsLabel || docsUrl}
</a>
</div>
)}
{commonIssues && commonIssues.length > 0 && (
<div>
<div className="text-ink-soft mb-1">Common errors:</div>
<ul className="space-y-1.5 pl-3">
{commonIssues.map((issue, i) => (
<li key={i}>
<code className="text-warm font-mono">{issue.symptom}</code>
<span className="text-ink-mid"> {issue.check}</span>
</li>
))}
</ul>
</div>
)}
</div>
</details>
);
}
function Field({
label,
value,
+5 -2
View File
@@ -77,7 +77,7 @@ export function Legend() {
onClick={openLegend}
aria-label="Show legend"
title="Show legend"
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line transition-[left,colors] duration-200`}
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`}
>
<span aria-hidden="true" className="text-[10px]"></span>
Legend
@@ -94,7 +94,10 @@ export function Legend() {
onClick={closeLegend}
aria-label="Hide legend"
title="Hide legend"
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-ink-soft hover:text-ink transition-colors"
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
×
</button>
+17 -6
View File
@@ -134,10 +134,12 @@ export function OnboardingWizard() {
aria-label="Onboarding guide"
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 */}
{/* Progress bar — was hardcoded from-blue-500 to-sky-400, neither
tone exists in warm-paper light theme. Switched to the accent
ramp so the gradient reads as brand color in both themes. */}
<div className="h-1 bg-surface-card">
<div
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
className="h-full bg-gradient-to-r from-accent to-accent-strong transition-all duration-500"
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
/>
</div>
@@ -155,14 +157,16 @@ export function OnboardingWizard() {
<div className="p-4">
{/* Step indicator */}
<div className="flex items-center justify-between mb-2">
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
{/* text-sky-400/80 was hardcoded; flip to text-accent so the
indicator stays brand-tinted in both themes. */}
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
Step {currentStepIdx + 1} of {STEPS.length}
</span>
<button
type="button"
onClick={dismiss}
aria-label="Skip onboarding guide"
className="text-[10px] text-ink-mid hover:text-ink transition-colors"
className="text-[10px] text-ink-mid hover:text-ink transition-colors rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Skip guide
</button>
@@ -181,7 +185,11 @@ export function OnboardingWizard() {
<button
type="button"
onClick={handleAction}
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"
// Was bg-accent-strong/90 hover:bg-accent — accent is the
// LIGHTER variant, so this hovered lighter on white text and
// dropped contrast below AA. Same trap fixed in
// ConfirmDialog/ApprovalBanner. Hover the OTHER direction.
className="flex-1 px-3 py-1.5 bg-accent hover:bg-accent-strong rounded-lg text-[11px] font-medium text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{step === "welcome"
? "Create Workspace"
@@ -199,7 +207,10 @@ export function OnboardingWizard() {
if (next) setStep(next.id);
else dismiss();
}}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card rounded-lg text-[11px] text-ink-mid transition-colors"
// Was hover:bg-surface-card on top of bg-surface-card —
// silent no-op hover. Lift to surface-elevated, matching
// the Cancel pattern in ConfirmDialog.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Next
</button>
@@ -293,7 +293,7 @@ export function OrgImportPreflightModal({
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-card text-ink-mid"
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel
</button>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent-strong hover:bg-accent text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
>
Import
</button>
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent-strong hover:bg-accent text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent-strong hover:bg-accent text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
+13 -6
View File
@@ -36,11 +36,6 @@ export function SearchDialog() {
}
}, [open]);
// Reset focused index when query changes
useEffect(() => {
setFocusedIndex(-1);
}, [query]);
const filtered = nodes.filter((n) => {
if (!query) return true;
const q = query.toLowerCase();
@@ -51,6 +46,18 @@ export function SearchDialog() {
);
});
// Auto-highlight the first match while the user is typing, so Enter
// selects something instead of being a no-op. With an empty query we
// keep -1 so opening the dialog (which shows ALL workspaces) doesn't
// visually pin one row arbitrarily — only commit a highlight once the
// user has narrowed the list.
useEffect(() => {
setFocusedIndex(query && filtered.length > 0 ? 0 : -1);
// Re-running on filtered.length keeps the highlight pinned to the
// first row while the result set shrinks/grows; the effect handler
// above already short-circuits to -1 when results disappear.
}, [query, filtered.length]);
const handleSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
@@ -113,7 +120,7 @@ export function SearchDialog() {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search workspaces..."
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"
className="flex-1 bg-transparent text-sm text-ink placeholder-ink-soft focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
/>
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">ESC</kbd>
</div>
+50 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { PLATFORM_URL } from "@/lib/api";
// TermsGate blocks the page it wraps until the user has accepted the
@@ -73,39 +73,72 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
}
};
// Move focus to the "I agree" button when the modal opens (WCAG 2.4.3).
// The dialog is a hard gate — no Esc dismiss — so we don't need a focus
// trap loop, just a one-shot focus move into the dialog.
const agreeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (status !== "pending") return;
const raf = requestAnimationFrame(() => agreeButtonRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [status]);
return (
<>
{children}
{status === "pending" && (
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
// Backdrop is decorative — does NOT carry aria-hidden anymore.
// The earlier version put aria-hidden="true" on this wrapper,
// which hid the dialog AND its descendants from screen readers,
// making the entire terms-acceptance flow invisible to AT users.
// Backdrop click intentionally does nothing — this is a hard
// gate.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="terms-dialog-title"
aria-describedby="terms-dialog-body"
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
>
<h2 id="terms-dialog-title" className="text-lg font-semibold text-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
</a>{" "}
and{" "}
<a href="/legal/privacy" className="text-sky-400 underline" target="_blank" rel="noreferrer">
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
<div id="terms-dialog-body">
<p className="mt-3 text-sm text-ink-mid">
Before you create an organization, please review our{" "}
<a
href="/legal/terms"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/legal/privacy"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
</div>
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
ref={agreeButtonRef}
onClick={accept}
disabled={submitting}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
// Hover goes DARKER, not lighter — emerald-500 on white
// text drops contrast below AA vs emerald-700. Same trap
// I fixed in ApprovalBanner + ConfirmDialog.
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{submitting ? "Saving…" : "I agree"}
</button>
+25 -2
View File
@@ -38,6 +38,18 @@ export function Toaster() {
};
}, []);
// Esc dismisses the newest toast — keyboard parity with the × button.
// Errors never auto-expire, so without this a keyboard-only user has to
// tab through the entire app to reach the dismiss button on a stuck error.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const toastCls = (type: Toast["type"]) =>
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
type === "success"
@@ -47,6 +59,17 @@ export function Toaster() {
: "bg-surface-sunken/90 border border-line/40 text-ink"
}`;
// Success/error toasts are intentionally dark in both themes (high-vis).
// Info uses the semantic surface that flips with theme — so the dismiss
// button needs a tint that stays visible on a light bg in light mode.
const dismissCls = (type: Toast["type"]) => {
const base =
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
return type === "info"
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
};
const pos =
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
@@ -66,7 +89,7 @@ export function Toaster() {
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
className={dismissCls(toast.type)}
>
×
</button>
@@ -94,7 +117,7 @@ export function Toaster() {
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
className={dismissCls(toast.type)}
>
×
</button>
+16 -16
View File
@@ -154,10 +154,10 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
<span className="text-ink-soft" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-soft whitespace-nowrap">
<span className="text-ink-mid" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-mid whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-ink-soft"> + {counts.children} sub</span>}
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
</span>
</div>
@@ -172,7 +172,7 @@ export function Toolbar() {
type="button"
onClick={stopAll}
disabled={stopping}
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1 bg-bad/10 hover:bg-bad/20 border border-bad/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-bad/40"
title={`Stop all running tasks (${counts.activeTasks} active)`}
aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`}
>
@@ -191,7 +191,7 @@ export function Toolbar() {
type="button"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartingAll}
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1 bg-warm/10 hover:bg-warm/20 border border-warm/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-warm/40"
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`}
>
@@ -216,10 +216,10 @@ export function Toolbar() {
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-accent"
: "bg-surface-card/50 hover:bg-surface-card/50 border-line/40 text-ink-soft hover:text-ink-mid"
? "bg-accent/15 hover:bg-accent/25 border-accent/50 text-accent"
: "bg-surface-card hover:bg-surface-card/70 border-line text-ink-mid hover:text-ink"
}`}
>
{/* Mesh / network icon */}
@@ -255,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-surface-card/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{/* Scroll / ledger icon */}
<svg
@@ -277,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-surface-card/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
@@ -290,7 +290,7 @@ export function Toolbar() {
<button
type="button"
onClick={() => setHelpOpen((open) => !open)}
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"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen}
aria-label="Open quick help"
title="Help — shortcuts & quick start"
@@ -308,7 +308,7 @@ export function Toolbar() {
<button
type="button"
onClick={() => setHelpOpen(false)}
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors"
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
>
Close
</button>
@@ -358,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-ink-soft" aria-hidden="true">Live</span>
<span className="text-[10px] text-ink-mid" aria-hidden="true">Live</span>
</div>
);
}
@@ -366,14 +366,14 @@ 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-ink-soft" aria-hidden="true">Reconnecting</span>
<span className="text-[10px] text-warm" aria-hidden="true">Reconnecting</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected" aria-label="Real-time updates: disconnected">
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
<span className="text-[10px] text-ink-soft" aria-hidden="true">Offline</span>
<span className="text-[10px] text-bad" aria-hidden="true">Offline</span>
</div>
);
}
@@ -384,7 +384,7 @@ function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
<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-ink-soft">{text}</p>
<p className="text-[11px] leading-relaxed text-ink-mid">{text}</p>
</div>
);
}
+18
View File
@@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) {
useEffect(() => () => clearTimeout(timerRef.current), []);
// WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism
// is available to dismiss the additional content WITHOUT moving
// pointer hover or keyboard focus. Esc dismisses while the trigger
// stays focused/hovered, so a screen-magnifier user can read what
// the tooltip was covering without losing their place.
useEffect(() => {
if (!show) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
clearTimeout(timerRef.current);
setShow(false);
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [show]);
const enter = useCallback(() => {
timerRef.current = setTimeout(() => {
if (triggerRef.current) {
@@ -130,6 +130,26 @@ describe("BatchActionBar", () => {
const toolbar = screen.getByRole("toolbar");
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
});
it("Esc clears the selection — matches the deselect button title", () => {
// The deselect button has been promising "Clear selection (Escape)"
// since the bar shipped, but no handler was wired. This pins the
// contract.
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
fireEvent.keyDown(window, { key: "Escape" });
expect(mockClearSelection).toHaveBeenCalled();
});
it("Esc is a no-op when nothing is selected", () => {
mockSelectedNodeIds = new Set<string>();
render(<BatchActionBar />);
fireEvent.keyDown(window, { key: "Escape" });
// The early-return at count===0 prevents the bar from mounting at all,
// so the keydown listener never registers. clearSelection must NOT be
// called.
expect(mockClearSelection).not.toHaveBeenCalled();
});
});
/**
@@ -40,7 +40,7 @@ afterEach(() => {
describe("CookieConsent", () => {
it("renders the banner when no decision is stored", () => {
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
expect(screen.getByRole("button", { name: "Accept all" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Necessary only" })).toBeTruthy();
});
@@ -48,7 +48,7 @@ describe("CookieConsent", () => {
it("stores 'accepted' and hides the banner when user clicks Accept all", () => {
render(<CookieConsent />);
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
const raw = window.localStorage.getItem(STORAGE_KEY);
expect(raw).not.toBeNull();
@@ -61,7 +61,7 @@ describe("CookieConsent", () => {
it("stores 'rejected' and hides the banner when user clicks Necessary only", () => {
render(<CookieConsent />);
fireEvent.click(screen.getByRole("button", { name: "Necessary only" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!);
expect(parsed.decision).toBe("rejected");
@@ -73,7 +73,7 @@ describe("CookieConsent", () => {
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }),
);
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
it("re-prompts when the stored decision is on an older policy version", () => {
@@ -82,13 +82,13 @@ describe("CookieConsent", () => {
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }),
);
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
});
it("re-prompts when localStorage contains invalid JSON", () => {
window.localStorage.setItem(STORAGE_KEY, "{not json");
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
});
it("exposes a privacy-policy link with target='_blank'", () => {
@@ -99,11 +99,19 @@ describe("CookieConsent", () => {
expect(link.getAttribute("rel")).toContain("noreferrer");
});
it("uses role=dialog with aria-labelledby and aria-describedby for screen readers", () => {
it("uses role=region (NOT dialog) with aria-labelledby/describedby — banner is informational, not modal", () => {
// Regression guard: an earlier version claimed role="dialog"
// aria-modal="true" without a focus trap. That falsely told screen
// readers the rest of the page was inert, trapping AT users in a
// banner they couldn't escape. role="region" lets assistive tech
// navigate around it normally; the banner stays informational.
render(<CookieConsent />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body");
const banner = screen.getByRole("region");
expect(banner.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
expect(banner.getAttribute("aria-describedby")).toBe("cookie-consent-body");
// No aria-modal claim — explicit guard against regression.
expect(banner.getAttribute("aria-modal")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does NOT render on local dev (non-SaaS hostname)", () => {
@@ -116,7 +124,7 @@ describe("CookieConsent", () => {
value: { ...window.location, hostname: "localhost" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
it("does NOT render on a LAN hostname (192.168.*, *.local)", () => {
@@ -125,7 +133,7 @@ describe("CookieConsent", () => {
value: { ...window.location, hostname: "192.168.1.74" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
});
@@ -155,18 +155,31 @@ describe("SearchDialog — keyboard accessibility", () => {
expect(selectNode).not.toHaveBeenCalled();
});
it("typing a new query resets focusedIndex to -1", () => {
it("typing a query that matches auto-highlights the first result", () => {
// Replaces the older "resets to -1" assertion. New behavior: a query
// with at least one match pins the highlight to row 0 so Enter picks
// a result instead of being a no-op. Empty-query case is covered by
// "Enter at focusedIndex=-1 does not select anything" above.
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "Alpha" } });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
// Enter on the auto-highlighted match should select it without
// needing a manual ArrowDown first.
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).toHaveBeenCalledWith("ws-1");
});
it("typing a query that matches NOTHING resets focusedIndex to -1", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
// Verify selection before reset
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
// Change query — triggers the useEffect that resets focusedIndex
fireEvent.change(input, { target: { value: "Alpha" } });
// After reset all options must have aria-selected="false"
screen.getAllByRole("option").forEach((opt) => {
expect(opt.getAttribute("aria-selected")).toBe("false");
});
fireEvent.change(input, { target: { value: "zzz-no-match" } });
// No options remain, so nothing to assert on aria-selected directly —
// the empty-state message takes over. But Enter should be a no-op.
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).not.toHaveBeenCalled();
});
it("aria-activedescendant matches the focused option's id", () => {
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { Toaster, showToast } from "../Toaster";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Toaster keyboard a11y", () => {
it("Esc dismisses the most recent toast", () => {
render(<Toaster />);
act(() => {
showToast("first", "info");
showToast("second", "info");
});
expect(screen.getByText("first")).toBeTruthy();
expect(screen.getByText("second")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByText("second")).toBeNull();
expect(screen.getByText("first")).toBeTruthy();
});
it("Esc dismisses persistent error toasts", () => {
render(<Toaster />);
act(() => {
showToast("boom", "error");
});
expect(screen.getByText("boom")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByText("boom")).toBeNull();
});
it("Esc with no toasts is a no-op", () => {
render(<Toaster />);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
// no throw, nothing rendered
expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0);
});
it("dismiss button has accessible label and is keyboard reachable", () => {
render(<Toaster />);
act(() => {
showToast("hi", "info");
});
const btn = screen.getByRole("button", { name: "Dismiss notification" });
expect(btn).toBeTruthy();
// Native <button> defaults to keyboard-focusable; explicit assertion guards
// against a future regression where someone adds tabindex=-1.
expect(btn.getAttribute("tabindex")).not.toBe("-1");
});
it("dismiss button click removes that specific toast", () => {
render(<Toaster />);
act(() => {
showToast("a", "info");
showToast("b", "info");
});
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
expect(buttons).toHaveLength(2);
// Click the first dismiss → "a" goes away, "b" stays
act(() => {
fireEvent.click(buttons[0]);
});
expect(screen.queryByText("a")).toBeNull();
expect(screen.getByText("b")).toBeTruthy();
});
});
+4 -1
View File
@@ -110,8 +110,11 @@ export function ActivityTab({ workspaceId }: Props) {
Full Trace
</button>
<button
type="button"
onClick={loadActivities}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[11px] rounded text-ink-mid"
// hover:bg-surface-card on top of itself was a no-op;
// lift to surface-elevated + focus-visible ring.
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[11px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
+5 -1
View File
@@ -365,8 +365,12 @@ export function ChannelsTab({ workspaceId }: Props) {
<p className="text-[10px] text-bad">{formError}</p>
)}
<button
type="button"
onClick={handleCreate}
className="w-full text-xs py-1.5 rounded bg-accent-strong hover:bg-accent text-white transition"
// Was bg-accent-strong hover:bg-accent — accent is the
// LIGHTER variant; same AA contrast trap fixed in
// ScheduleTab/MemoryTab/OnboardingWizard.
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Connect Channel
</button>
+34 -9
View File
@@ -177,10 +177,10 @@ export function ChatTab({ workspaceId, data }: Props) {
aria-controls="chat-panel-my-chat"
tabIndex={subTab === "my-chat" ? 0 : -1}
onClick={() => setSubTab("my-chat")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
className={`px-3 py-1.5 text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
subTab === "my-chat"
? "text-ink border-b-2 border-accent"
: "text-ink-soft hover:text-ink-mid"
: "text-ink-mid hover:text-ink"
}`}
>
My Chat
@@ -192,10 +192,10 @@ export function ChatTab({ workspaceId, data }: Props) {
aria-controls="chat-panel-agent-comms"
tabIndex={subTab === "agent-comms" ? 0 : -1}
onClick={() => setSubTab("agent-comms")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
className={`px-3 py-1.5 text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
subTab === "agent-comms"
? "text-ink border-b-2 border-accent"
: "text-ink-soft hover:text-ink-mid"
: "text-ink-mid hover:text-ink"
}`}
>
Agent Comms
@@ -773,14 +773,39 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user"
? "bg-accent text-white border border-accent-strong"
// Solid blue-600 in both modes — `bg-accent` themes
// lighter in dark, dropping white-text contrast to
// ~3:1 (fails AA). blue-600 keeps ~5:1 against white
// on both warm-paper and dark-slate panels.
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
: msg.role === "system"
? "bg-bad/10 text-bad border border-bad/40"
: "bg-surface-card text-ink border border-line"
// Bump the system bubble's opacity in dark — /10
// overlay was nearly invisible against the dark
// panel bg.
? "bg-bad/10 text-bad border border-bad/40 dark:bg-bad/25 dark:text-bad dark:border-bad/60"
// Agent bubble in dark: surface-card (#1a1d23) is
// only ~7% lighter than the panel bg-surface
// (#0e1014). Bump to zinc-700 for a clearly
// elevated bubble; light mode keeps the warm
// surface-card tint.
: "bg-surface-card text-ink border border-line dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-600 shadow-sm"
}`}
>
{msg.content && (
<div className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${msg.role === "user" ? "prose-invert" : ""}`}>
<div
className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${
msg.role === "user"
? "prose-invert"
// Agent bubbles in dark mode: invert prose AND brighten
// the body/heading/bold/code tokens. prose-invert's
// default `--tw-prose-invert-body: zinc-300` lands at
// ~5.3:1 against bg-zinc-700 — passes AA but reads
// washed out next to the user bubble's crisp
// white-on-blue (~10:1). Push body to zinc-100 so the
// agent text matches that crispness.
: "dark:prose-invert dark:[--tw-prose-invert-body:theme(colors.zinc.100)] dark:[--tw-prose-invert-headings:theme(colors.white)] dark:[--tw-prose-invert-bold:theme(colors.white)] dark:[--tw-prose-invert-code:theme(colors.zinc.100)]"
}`}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
)}
@@ -896,7 +921,7 @@ 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-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"
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
/>
<button
onClick={sendMessage}
+4 -3
View File
@@ -65,11 +65,11 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
<div className="flex gap-2">
<button type="button" onClick={handleSave} disabled={saving}
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-50">
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
{saving ? "Saving..." : "Save"}
</button>
<button type="button" onClick={() => setEditing(false)}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
) : (
@@ -956,7 +956,8 @@ export function ConfigTab({ workspaceId }: Props) {
type="button"
onClick={() => handleSave(true)}
disabled={!isDirty || saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-30 transition-colors"
// Same accent-LIGHTER fix shipped on every other tab.
className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{saving ? "Restarting..." : "Save & Restart"}
</button>
+11 -3
View File
@@ -166,7 +166,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
type="button"
onClick={handleSave}
disabled={saving}
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-50"
// Was bg-accent-strong hover:bg-accent — accent is the
// LIGHTER variant; flipped + focus-visible ring (same
// trap fix shipped on every other tab).
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{saving ? "Saving..." : "Save"}
</button>
@@ -322,7 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={handleDelete}
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
// hover:bg-red-500 LIGHTER on white text drops AA;
// flipped to bg-red-700 + focus-visible danger ring,
// matching the ConfirmDialog/DeleteCascade pattern.
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Confirm Delete
</button>
@@ -334,7 +340,9 @@ 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-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
// Was hover:bg-surface-card on top of itself (no-op);
// lift to surface-elevated.
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel
</button>
+55 -33
View File
@@ -15,14 +15,20 @@ interface EventEntry {
created_at: string;
}
// Use semantic warm-paper tokens so colors flip with theme. Earlier
// the table referenced text-yellow-400 / text-purple-400 (Tailwind
// raw colors, no theme variant), which read fine in dark mode but
// washed out in the warm-paper light theme. text-warm covers the
// "degraded" amber tone in both modes; AGENT_CARD_UPDATED is informational
// metadata, so reuse text-accent for theme-consistency.
const EVENT_COLORS: Record<string, string> = {
WORKSPACE_ONLINE: "text-good",
WORKSPACE_OFFLINE: "text-ink-mid",
WORKSPACE_DEGRADED: "text-yellow-400",
WORKSPACE_DEGRADED: "text-warm",
WORKSPACE_PROVISIONING: "text-accent",
WORKSPACE_REMOVED: "text-bad",
WORKSPACE_PROVISION_FAILED: "text-bad",
AGENT_CARD_UPDATED: "text-purple-400",
AGENT_CARD_UPDATED: "text-accent",
};
export function EventsTab({ workspaceId }: Props) {
@@ -64,8 +70,12 @@ export function EventsTab({ workspaceId }: Props) {
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-ink-mid">{events.length} events</span>
<button
type="button"
onClick={loadEvents}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
// Was hover:bg-surface-card on top of bg-surface-card — silent
// no-op hover. Lift to surface-elevated, matching the Cancel
// pattern from ConfirmDialog.
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
@@ -81,39 +91,51 @@ export function EventsTab({ workspaceId }: Props) {
<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-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-ink-mid"
}`}
{events.map((event) => {
const isOpen = expanded === event.id;
const panelId = `events-payload-${event.id}`;
return (
<div key={event.id} className="bg-surface-card rounded border border-line">
<button
type="button"
onClick={() => setExpanded(isOpen ? null : event.id)}
// aria-expanded + aria-controls so screen readers
// announce the open/closed state and link the row to
// its payload panel. Without these, AT users hear
// a generic "button" with no indication that it
// toggles or what it controls.
aria-expanded={isOpen}
aria-controls={panelId}
className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
>
{event.event_type}
</span>
<span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)}
</span>
<span className="text-[10px] text-ink-soft">
{expanded === event.id ? "▼" : "▶"}
</span>
</button>
<span
className={`text-xs font-mono ${
EVENT_COLORS[event.event_type] || "text-ink-mid"
}`}
>
{event.event_type}
</span>
<span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)}
</span>
<span aria-hidden="true" className="text-[10px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</button>
{expanded === event.id && (
<div className="px-3 pb-2">
<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-ink-soft font-mono">
ID: {event.id}
{isOpen && (
<div id={panelId} className="px-3 pb-2">
<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-ink-soft font-mono">
ID: {event.id}
</div>
</div>
</div>
)}
</div>
))}
)}
</div>
);
})}
</div>
)}
</div>
+13 -9
View File
@@ -162,25 +162,29 @@ 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-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
// role=alertdialog so SR users hear this destructive prompt
// immediately. Delete-All hovers DARKER (bg-red-700) — same AA
// contrast trap that bit ConfirmDialog/ApprovalBanner. Cancel
// lifts to surface-elevated instead of the prior no-op hover.
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
<p id="files-delete-all-msg" 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-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
)}
{error && (
<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>
<div role="alert" 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-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
<p id="files-delete-one-msg" 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-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
)}
+14 -10
View File
@@ -137,14 +137,14 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAwareness((prev) => !prev)}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
>
{showAwareness ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={openAwareness}
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
>
Open
</button>
@@ -177,7 +177,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAwareness(true)}
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
Expand
</button>
@@ -212,21 +212,21 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
>
{showAdvanced ? "Hide Advanced" : "Advanced"}
</button>
<button
type="button"
onClick={loadMemory}
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
>
Refresh
</button>
<button
type="button"
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
+ Add
</button>
@@ -262,7 +262,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={handleAdd}
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white"
className="px-3 py-1 bg-accent hover:bg-accent-strong 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-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid"
>
Cancel
</button>
@@ -318,7 +318,11 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-bad hover:text-bad"
// hover:text-bad on top of text-bad was a no-op.
// Switch to a hover bg + focus-visible ring so
// the destructive button visibly responds and
// keyboard users see focus.
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
>
Delete
</button>
@@ -340,7 +344,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
>
Show
</button>
+10 -2
View File
@@ -269,15 +269,23 @@ export function ScheduleTab({ workspaceId }: Props) {
{error && <div className="text-[10px] text-bad">{error}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={!formCron || !formPrompt}
className="text-[11px] px-3 py-1 bg-accent-strong text-white rounded hover:bg-accent disabled:opacity-40 transition-colors"
// Was bg-accent-strong hover:bg-accent — accent is the
// LIGHTER variant, so this hovered lighter on white text
// and dropped contrast below AA. Same trap fixed in
// OnboardingWizard, ConfirmDialog, ApprovalBanner.
className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{editId ? "Update" : "Create"}
</button>
<button
type="button"
onClick={resetForm}
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-card transition-colors"
// Was hover:bg-surface-card on top of bg-surface-card —
// silent no-op hover. Lift to surface-elevated.
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel
</button>
+1 -1
View File
@@ -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-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft 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:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
/>
<button
onClick={handleInstallCustom}
+15 -7
View File
@@ -123,15 +123,18 @@ export function TerminalTab({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Status bar — role="status" so connection state changes are announced politely */}
{/* Status bar — role="status" so connection state changes are announced politely.
Terminal body stays dark unconditionally (Canvas v4 design rule), but the
chrome wrapping it now uses semantic status colors so the dot/text stay
readable in both themes. */}
<div role="status" aria-live="polite" className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
status === "connected" ? "bg-green-500" :
status === "connecting" ? "bg-yellow-500 motion-safe:animate-pulse" :
status === "error" ? "bg-red-500" : "bg-zinc-500"
status === "connected" ? "bg-good" :
status === "connecting" ? "bg-warm motion-safe:animate-pulse" :
status === "error" ? "bg-bad" : "bg-ink-soft"
}`} />
<span className="text-[10px] text-zinc-400">
<span className="text-[10px] text-zinc-300">
{status === "connected" ? "Shell active" :
status === "connecting" ? "Connecting..." :
status === "error" ? "Connection failed" : "Disconnected"}
@@ -139,8 +142,13 @@ export function TerminalTab({ workspaceId }: Props) {
</div>
{(status === "disconnected" || status === "error") && (
<button
type="button"
onClick={reconnect}
className="text-[10px] text-blue-400 hover:text-blue-300"
// Accent over hardcoded blue. text-accent + hover-strong stays
// readable on the dark terminal chrome and matches the rest
// of the canvas semantic palette. Focus-visible ring added so
// keyboard users see where focus lands on a recovery button.
className="text-[10px] text-accent hover:text-accent-strong rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
Reconnect
</button>
@@ -149,7 +157,7 @@ export function TerminalTab({ workspaceId }: Props) {
{/* Error message — role="alert" announces immediately via assertive live region */}
{errorMsg && (
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{errorMsg}
</div>
)}
+79 -60
View File
@@ -55,7 +55,13 @@ export function TracesTab({ workspaceId }: Props) {
<div className="p-4 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-ink-mid">{traces.length} traces</span>
<button type="button" onClick={loadTraces} className="text-[10px] text-ink-soft hover:text-ink-mid">
<button
type="button"
onClick={loadTraces}
// Added focus-visible ring; previous version was hover-only,
// invisible to keyboard users.
className="text-[10px] text-ink-soft hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
</div>
@@ -79,66 +85,79 @@ export function TracesTab({ workspaceId }: Props) {
</div>
) : (
<div className="space-y-1">
{traces.map((trace) => (
<div key={trace.id} className="bg-surface-card/40 border border-line/40 rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(expanded === trace.id ? null : trace.id)}
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 transition-colors"
>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
trace.status === "ERROR" ? "bg-red-400" : "bg-emerald-400"
}`} />
<div className="flex-1 min-w-0">
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{trace.latency != null && (
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
</span>
)}
{trace.usage?.total != null && (
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.usage.total} tok
</span>
)}
<span className="text-[9px] text-ink-soft">
{expanded === trace.id ? "▼" : "▶"}
</span>
</div>
</button>
{expanded === trace.id && (
<div className="px-3 pb-2 space-y-2 border-t border-line/30">
{trace.input && (
<div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
</pre>
</div>
)}
{trace.output && (
<div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
</pre>
</div>
)}
{trace.totalCost != null && (
<div className="text-[9px] text-ink-soft">
Cost: ${trace.totalCost.toFixed(6)}
</div>
)}
<div className="text-[8px] text-ink-soft font-mono select-all">
{trace.id}
{traces.map((trace) => {
const isOpen = expanded === trace.id;
const panelId = `trace-detail-${trace.id}`;
return (
<div key={trace.id} className="bg-surface-card/40 border border-line/40 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setExpanded(isOpen ? null : trace.id)}
// aria-expanded + aria-controls so SR announces the
// open/closed state and links the row to its detail
// panel. Same pattern shipped on EventsTab.
aria-expanded={isOpen}
aria-controls={panelId}
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
>
{/* Status dot uses semantic bad/good tokens — was hardcoded
bg-red-400 / bg-emerald-400 which doesn't pin to the
canvas-wide ramp. */}
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
trace.status === "ERROR" ? "bg-bad" : "bg-good"
}`} />
<div className="flex-1 min-w-0">
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
</div>
</div>
)}
</div>
))}
<div className="flex items-center gap-2 shrink-0">
{trace.latency != null && (
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
</span>
)}
{trace.usage?.total != null && (
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.usage.total} tok
</span>
)}
<span aria-hidden="true" className="text-[9px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</div>
</button>
{isOpen && (
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
{trace.input && (
<div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
</pre>
</div>
)}
{trace.output && (
<div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
</pre>
</div>
)}
{trace.totalCost != null && (
<div className="text-[9px] text-ink-soft">
Cost: ${trace.totalCost.toFixed(6)}
</div>
)}
<div className="text-[8px] text-ink-soft font-mono select-all">
{trace.id}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
@@ -472,6 +472,7 @@ function GroupedCommsView({
<NormalMessage key={msg.id} msg={msg} />
),
)}
<WaitingBubbles visible={visible} />
<div ref={bottomRef} />
</div>
</div>
@@ -560,6 +561,83 @@ function PeerTabButton({
);
}
/** WaitingBubbles renders one "typing" bubble per peer that has an
* in-flight outbound delegation — i.e., the most recent outbound
* message to that peer is still pending or queued and no later inbound
* reply has arrived. Mirrors the bouncing-dots indicator in ChatTab so
* the operator sees the same visual cue regardless of whether they're
* watching their own chat or a peer thread.
*
* Why "per peer" not "one global": when multiple delegations are in
* flight to different peers (common during fan-out), one shared
* spinner under-reports — the user can't tell whether ALL peers are
* still working or only the visible ones. Per-peer matches Slack-style
* typing indicators and keeps the signal honest.
*
* Why we look at the LAST per-peer message: once a peer replies (an
* "in" bubble lands), the corresponding "out" bubble is no longer the
* tail — even if status hasn't been mutated to "completed", the inbound
* reply means the wait is over. Looking at the tail collapses both
* cases into one rule.
*/
function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
// Group by peer, keep only the chronologically-last message per peer,
// emit a bubble when that tail is an outbound pending/queued.
const tailByPeer = new Map<string, CommMessage>();
for (const m of visible) {
const prev = tailByPeer.get(m.peerId);
if (!prev || m.timestamp > prev.timestamp) tailByPeer.set(m.peerId, m);
}
const waitingPeers = Array.from(tailByPeer.values()).filter(
(m) => m.flow === "out" && (m.status === "pending" || m.status === "queued"),
);
if (waitingPeers.length === 0) return null;
return (
<>
{waitingPeers.map((m) => (
<div
key={`waiting-${m.peerId}`}
className="flex justify-end"
// Outbound thread → right-justified to match the "out" bubble
// alignment, so the dots feel like they belong to the message
// they're replying to.
>
<div
className="max-w-[85%] rounded-lg px-3 py-2 text-xs bg-cyan-900/30 border border-cyan-700/20"
// role+aria-label so screen readers announce the wait;
// matches the announcing pattern used by Toaster.
role="status"
aria-label={`Waiting for reply from ${m.peerName}`}
>
<div className="text-[9px] text-ink-soft mb-1"> To {m.peerName}</div>
<span className="flex items-center gap-2 text-ink-mid">
<span className="flex gap-0.5" aria-hidden="true">
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</span>
<span className="text-[10px]">
{m.status === "queued"
? `${m.peerName} is busy — reply will arrive when they're free`
: `Waiting for ${m.peerName}`}
</span>
</span>
</div>
</div>
))}
</>
);
}
function NormalMessage({ msg }: { msg: CommMessage }) {
return (
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>
@@ -574,12 +652,22 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
</div>
{msg.text ? (
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
// Outgoing bubble (cyan-900) is dark in both themes → prose-invert default.
// Incoming bubble (surface-card) themes light → only invert in dark.
<MarkdownBody
className="text-ink-mid"
invert={msg.flow === "out" ? "always" : "dark-only"}
>
{msg.text}
</MarkdownBody>
) : (
<div className="text-ink-mid">(no message text)</div>
)}
{msg.responseText && (
<MarkdownBody className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid">
<MarkdownBody
className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid"
invert={msg.flow === "out" ? "always" : "dark-only"}
>
{msg.responseText}
</MarkdownBody>
)}
@@ -706,17 +794,29 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
* prose tweaks that keep paragraphs tight inside a small bubble.
* Code blocks get an `overflow-x-auto` so a long line of code doesn't
* blow out the bubble's max-width — agent-to-agent replies routinely
* ship code samples and JSON. */
* ship code samples and JSON.
*
* `invert` controls the prose color flip:
* - "always": container bg is dark in BOTH themes (cyan-900, red-950),
* so prose always wants light body text.
* - "dark-only": container bg uses a theming token that goes light in
* light mode (e.g. bg-surface-card). Prose only inverts in dark
* mode; light mode keeps default dark prose colors against the
* light bg. Without this, light mode rendered light text on light
* bg = invisible markdown. */
function MarkdownBody({
children,
className,
invert = "always",
}: {
children: string;
className?: string;
invert?: "always" | "dark-only";
}) {
const proseInvert = invert === "always" ? "prose-invert" : "dark:prose-invert";
return (
<div
className={`prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
className={`prose prose-sm ${proseInvert} max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Per-runtime model slug dispatch for E2E provisioning.
#
# Different runtimes parse the model slug differently (PR #2571 incident,
# 2026-05-03):
#
# hermes → "openai/gpt-4o" (slash-form: derive-provider.sh splits
# on the prefix to set
# HERMES_INFERENCE_PROVIDER. Bare
# "gpt-4o" falls through to Anthropic
# default + 401, see PR #1714.)
#
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
# requires "<provider>:<model>".
# Slash-form was misinterpreted as
# OpenRouter routing → fell through
# without auth, surfaced 2026-05-03
# after the a2a-sdk v1 contract bugs
# PR #2558+#2563+#2567 cleared the
# masking layers.)
#
# claude-code → "sonnet" (entry-id form: claude-code template's
# config.yaml uses bare model names,
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
# or ANTHROPIC_API_KEY rather than the
# slug.)
#
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
# operator dispatches the workflow to test a specific slug.
#
# Unit tested by tests/e2e/test_model_slug.sh — every branch must stay
# pinned because regressions silently mask as "Could not resolve
# authentication method" + the synth-E2E gate goes red without naming
# the slug-format mismatch.
# Usage: pick_model_slug <runtime>
# stdout: the slug string
# E2E_MODEL_SLUG (env): if set + non-empty, used as-is (operator override)
pick_model_slug() {
local runtime="${1:-}"
if [ -n "${E2E_MODEL_SLUG:-}" ]; then
printf '%s' "$E2E_MODEL_SLUG"
return 0
fi
case "$runtime" in
hermes) printf 'openai/gpt-4o' ;;
langgraph) printf 'openai:gpt-4o' ;;
claude-code) printf 'sonnet' ;;
*) printf 'openai/gpt-4o' ;; # safest fallback (matches hermes)
esac
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Regression test for tests/e2e/lib/model_slug.sh.
#
# PR #2571 fixed a synth-E2E masking bug where MODEL_SLUG was hardcoded
# to "openai/gpt-4o" (slash-form) but langgraph's init_chat_model needs
# "openai:gpt-4o" (colon-form). Fix shipped as a per-runtime case
# statement. Without this regression test, dropping any branch of the
# case (or flipping a slug format) would silently revert behavior — the
# E2E only fails as "Could not resolve authentication method" at the
# very first message, after a successful tenant + workspace provision.
#
# Each branch must FAIL the test if the dispatch behavior changes, not
# just produce some non-empty string.
set -uo pipefail
# Resolve to the lib relative to this test file so the test runs from
# any cwd (CI, local invocation, repo root).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/model_slug.sh
source "$SCRIPT_DIR/lib/model_slug.sh"
PASS=0
FAIL=0
assert_eq() {
local label="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo "$label"
PASS=$((PASS+1))
else
echo "$label: got=$(printf %q "$got") want=$(printf %q "$want")" >&2
FAIL=$((FAIL+1))
fi
}
run_test() {
local label="$1" runtime="$2" want="$3"
# Pin per-test isolation: explicitly unset the override so a leaked
# E2E_MODEL_SLUG from caller env can't poison the dispatch branches.
local got
got=$(unset E2E_MODEL_SLUG; pick_model_slug "$runtime")
assert_eq "$label" "$got" "$want"
}
echo "Test: pick_model_slug — per-runtime dispatch"
echo
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
run_test "claude-code → bare model name (entry-id form)" claude-code "sonnet"
# ── Fallback for unknown runtime ──
# Picks slash-form (hermes-shaped) since hermes is the historical
# default and most third-party runtimes behave hermes-like. Pinning
# this so a future "smarter" fallback (e.g., empty string, error) is
# a deliberate choice, not silent drift.
run_test "unknown runtime → slash-form fallback" gemini "openai/gpt-4o"
run_test "empty runtime → slash-form fallback" "" "openai/gpt-4o"
# ── Override via E2E_MODEL_SLUG ──
# When the operator sets E2E_MODEL_SLUG, the per-runtime dispatch is
# bypassed. Used during workflow_dispatch to A/B specific slugs.
echo
echo "Test: pick_model_slug — E2E_MODEL_SLUG override"
echo
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug langgraph)
assert_eq "override beats langgraph default" "$got" "anthropic:claude-opus-4-7"
got=$(E2E_MODEL_SLUG="custom/whatever" pick_model_slug hermes)
assert_eq "override beats hermes default" "$got" "custom/whatever"
got=$(E2E_MODEL_SLUG="some-bare-id" pick_model_slug claude-code)
assert_eq "override beats claude-code default" "$got" "some-bare-id"
# Empty-string override does NOT activate (falls through to dispatch).
# This is the historical bash idiom: -n "" → false → no override. Pin
# it because changing this behavior (e.g. via -v test) would silently
# break the dispatch when an operator passes "" to clear an inherited
# env var.
got=$(E2E_MODEL_SLUG="" pick_model_slug langgraph)
assert_eq "empty-string override falls through to dispatch" "$got" "openai:gpt-4o"
echo
echo "─────────────────────────────────────────────────"
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
echo "─────────────────────────────────────────────────"
[ "$FAIL" -eq 0 ]
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Regression test for the SECRETS_JSON branching in
# tests/e2e/test_staging_full_saas.sh (lines ~322-368).
#
# The synth-E2E canary picks one of two LLM auth paths based on which
# E2E_*_API_KEY is set. The branch order is load-bearing:
#
# E2E_MINIMAX_API_KEY first → claude-code MiniMax path (cheap canary
# default since 2026-05-03; routes via
# workspace-configs-templates/claude-
# code-default/config.yaml's `minimax`
# provider entry).
#
# E2E_OPENAI_API_KEY second → langgraph + hermes legacy path (kept
# as fallback for operator dispatches
# that need the OpenAI-shaped
# HERMES_CUSTOM_* env block).
#
# Without this gate, a future "tidy up the if/elif" refactor could
# silently flip the precedence (OpenAI wins when both are set →
# claude-code workspace boots without MINIMAX_API_KEY → 401 at first
# turn → canary red without any signal that the wrong key shape was
# selected). The 2026-05-03 OpenAI-quota incident took ~16h to
# diagnose for exactly this class of "looks like an LLM problem,
# was actually a wiring problem" failure.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SAAS_SCRIPT="$SCRIPT_DIR/test_staging_full_saas.sh"
if [ ! -f "$SAAS_SCRIPT" ]; then
echo "FATAL: cannot locate test_staging_full_saas.sh at $SAAS_SCRIPT" >&2
exit 2
fi
PASS=0
FAIL=0
assert_eq() {
local label="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo "$label"
PASS=$((PASS+1))
else
echo "$label" >&2
echo " got: $got" >&2
echo " want: $want" >&2
FAIL=$((FAIL+1))
fi
}
# Extract just the SECRETS_JSON block from the saas script and source
# it into a sub-shell so we can run the branching logic in isolation.
# Anchor on the comment header so a structural refactor that moves the
# block fails this test loudly rather than silently sourcing nothing.
extract_block() {
awk '
/^# ─── 5\. Provision parent workspace/ {capture=1; next}
capture && /^MODEL_SLUG=/ {exit}
capture {print}
' "$SAAS_SCRIPT"
}
BLOCK=$(extract_block)
if [ -z "$BLOCK" ]; then
echo "FATAL: SECRETS_JSON block not found in $SAAS_SCRIPT — refactor anchor changed?" >&2
exit 2
fi
# Run the extracted block in a clean env, capturing SECRETS_JSON.
run_block() {
# Caller passes vars on the command line, e.g.
# run_block E2E_MINIMAX_API_KEY=mx-test
env -i PATH="$PATH" "$@" bash -c "
set -uo pipefail
$BLOCK
echo \"\$SECRETS_JSON\"
" 2>/dev/null | tail -1
}
# Resolve a JSON key from the captured payload using python3 (already
# a hard dep of the saas script). Returns empty string on missing key.
get_json_key() {
local payload="$1" key="$2"
python3 -c "
import json, sys
p = json.loads(sys.argv[1])
print(p.get(sys.argv[2], ''))
" "$payload" "$key"
}
list_json_keys() {
python3 -c "
import json, sys
p = json.loads(sys.argv[1])
print(','.join(sorted(p.keys())))
" "$1"
}
echo "Test: SECRETS_JSON branching in test_staging_full_saas.sh"
echo
# ── Branch 1: MiniMax wins when set ──
SECRETS_JSON=$(run_block E2E_MINIMAX_API_KEY=mx-test)
assert_eq "MiniMax key set → MINIMAX_API_KEY in payload" \
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" "mx-test"
assert_eq "MiniMax-only payload contains exactly MINIMAX_API_KEY" \
"$(list_json_keys "$SECRETS_JSON")" "MINIMAX_API_KEY"
# ── Branch 1 precedence: MiniMax beats OpenAI when both set ──
# Critical: the 2026-05-03 incident shape was "two paths exist, wrong
# one wins". The bash if/elif must keep MiniMax above OpenAI so the
# claude-code default canary doesn't accidentally use the (more
# expensive, quota-burnt) OpenAI key.
SECRETS_JSON=$(run_block E2E_MINIMAX_API_KEY=mx-priority E2E_OPENAI_API_KEY=oai-loser)
assert_eq "Both keys set → MiniMax wins" \
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" "mx-priority"
assert_eq "Both keys set → OpenAI block NOT emitted" \
"$(get_json_key "$SECRETS_JSON" OPENAI_API_KEY)" ""
assert_eq "Both keys set → no HERMES_* leakage from OpenAI branch" \
"$(get_json_key "$SECRETS_JSON" HERMES_INFERENCE_PROVIDER)" ""
# ── Branch 2: OpenAI used when MiniMax absent ──
SECRETS_JSON=$(run_block E2E_OPENAI_API_KEY=oai-test)
assert_eq "Only OpenAI set → OPENAI_API_KEY in payload" \
"$(get_json_key "$SECRETS_JSON" OPENAI_API_KEY)" "oai-test"
assert_eq "Only OpenAI set → HERMES_CUSTOM_API_KEY mirrors OpenAI key" \
"$(get_json_key "$SECRETS_JSON" HERMES_CUSTOM_API_KEY)" "oai-test"
assert_eq "Only OpenAI set → MODEL_PROVIDER pinned to colon-form" \
"$(get_json_key "$SECRETS_JSON" MODEL_PROVIDER)" "openai:gpt-4o"
assert_eq "Only OpenAI set → MINIMAX_API_KEY NOT emitted" \
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" ""
# ── No keys: empty payload ──
SECRETS_JSON=$(run_block)
assert_eq "No keys set → SECRETS_JSON is empty object" \
"$SECRETS_JSON" "{}"
echo
echo "─────────────────────────────────────────────────"
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
echo "─────────────────────────────────────────────────"
[ "$FAIL" -eq 0 ]
+86 -58
View File
@@ -67,6 +67,12 @@ log() { echo "[$(date +%H:%M:%S)] $*"; }
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
# Per-runtime model slug dispatch — see lib/model_slug.sh for the rationale.
# Extracted so unit tests (tests/e2e/test_model_slug.sh) can pin every branch
# without booting the full 11-step lifecycle.
# shellcheck source=lib/model_slug.sh
source "$(dirname "$0")/lib/model_slug.sh"
CURL_COMMON=(-sS --fail-with-body --max-time 30)
# ─── cleanup trap ───────────────────────────────────────────────────────
@@ -314,29 +320,39 @@ tenant_call() {
}
# ─── 5. Provision parent workspace ─────────────────────────────────────
# Runtimes like hermes crash at boot with "No provider API key found"
# if nothing in the standard env-var list is set. Inject the API key
# from E2E_OPENAI_API_KEY so the runtime can actually start — it's
# per-workspace secret, so it's persisted as a workspace_secret and
# materialized into the container env. Missing key falls through to
# an empty secrets map; workspace will still fail but the error is
# expected and actionable.
# Inject the LLM provider key so the runtime can authenticate at boot.
# Branch by which secret is set so the script supports both paths
# without forcing every dispatch to ship both keys:
#
# E2E_MINIMAX_API_KEY → claude-code MiniMax path. Cheapest, default
# for the cron canary post-2026-05-03. Routes via the claude-code
# template's `minimax` provider (workspace-configs-templates/
# claude-code-default/config.yaml:64-69) which sets
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic at boot.
# MINIMAX_API_KEY is the vendor-specific env name the adapter
# reads (PR #244 — per-vendor envs prevent ANTHROPIC_AUTH_TOKEN
# collisions when a user runs MiniMax + Z.ai workspaces side-by-
# side).
#
# E2E_OPENAI_API_KEY → langgraph + hermes paths. Kept as fallback
# for operator dispatches that explicitly want to exercise the
# OpenAI path. The HERMES_* fields pin hermes-agent's bridge to
# api.openai.com (template-hermes' derive-provider.sh otherwise
# resolves openai/* → openrouter.ai and 401s). MODEL_PROVIDER
# follows workspace/config.py:258's 'provider:model' format.
#
# Both empty → '{}' (workspace will fail at first turn with an
# expected, actionable auth error rather than masking the test).
SECRETS_JSON='{}'
if [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
# MODEL_PROVIDER is a full model slug in 'provider:model' format per
# workspace/config.py:258. Using just "openai" gets parsed as the
# model name → 404 model_not_found. Also set OPENAI_BASE_URL to
# OpenAI's own endpoint — default is openrouter.ai which would need
# a different key format.
#
# The HERMES_* fields below bypass template-hermes/scripts/derive-provider.sh
# — verified 2026-04-24 that even with template-hermes#19's fix in main,
# staging tenants sometimes resolve openai/* to PROVIDER=openrouter and
# emit {'message':'Missing Authentication header','code':401} (OpenRouter's
# shape) in the A2A reply. Setting HERMES_INFERENCE_PROVIDER=custom +
# HERMES_CUSTOM_{BASE_URL,API_KEY,API_MODE} pins the bridge deterministically
# so the test doesn't depend on every tenant EC2 having a freshly-cloned
# template-hermes.
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
SECRETS_JSON=$(python3 -c "
import json, os
k = os.environ['E2E_MINIMAX_API_KEY']
print(json.dumps({
'MINIMAX_API_KEY': k,
}))
")
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
SECRETS_JSON=$(python3 -c "
import json, os
k = os.environ['E2E_OPENAI_API_KEY']
@@ -352,42 +368,7 @@ print(json.dumps({
")
fi
# Model slug format depends on the runtime — different model resolvers
# parse it differently:
#
# hermes → "openai/gpt-4o" (slash-form: derive-provider.sh splits
# on the prefix to set
# HERMES_INFERENCE_PROVIDER. Bare
# "gpt-4o" falls through to Anthropic
# default + 401, see PR #1714.)
#
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
# requires "<provider>:<model>".
# Slash-form was misinterpreted as
# OpenRouter routing → fell through
# without auth, surfaced 2026-05-03
# after the a2a-sdk v1 contract bugs
# PR #2558+#2563+#2567 cleared the
# masking layers.)
#
# claude-code → "sonnet" (entry-id form: claude-code template's
# config.yaml uses bare model names,
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
# or ANTHROPIC_API_KEY rather than the
# slug.)
#
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
# operator dispatches the workflow to test a specific slug.
if [ -n "${E2E_MODEL_SLUG:-}" ]; then
MODEL_SLUG="$E2E_MODEL_SLUG"
else
case "$RUNTIME" in
hermes) MODEL_SLUG="openai/gpt-4o" ;;
langgraph) MODEL_SLUG="openai:gpt-4o" ;;
claude-code) MODEL_SLUG="sonnet" ;;
*) MODEL_SLUG="openai/gpt-4o" ;; # safest fallback (matches hermes)
esac
fi
MODEL_SLUG=$(pick_model_slug "$RUNTIME")
log "5/11 Provisioning parent workspace (runtime=$RUNTIME)..."
PARENT_RESP=$(tenant_call POST /workspaces \
@@ -458,6 +439,42 @@ for wid in $WS_TO_CHECK; do
ok " $wid online"
done
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
# This step exists because the canvas-terminal failure of 2026-05-03
# was structurally invisible to local-dev (handleLocalConnect uses
# docker exec; handleRemoteConnect uses EIC + ssh). The CP provisioner
# shipped without the tcp/22 EIC ingress rule for ~6 months and nobody
# noticed until a paying tenant clicked Terminal in canvas. Probing the
# diagnose endpoint here at synth-E2E time means a regression in
# - tenantIngressRules / workspaceIngressRules (CP)
# - eicSSHIngressRule helper (CP)
# - AuthorizeIngress source-group support (CP awsapi)
# - EIC_ENDPOINT_SG_ID Railway env
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
# surfaces within ~20 min of merge instead of waiting for a user report.
#
# The diagnose endpoint runs the full EIC + ssh probe from inside the
# tenant's workspace-server (which already has AWS creds via its IAM
# profile) and reports per-step status. We only need to call it as the
# tenant — no AWS creds needed on the GHA runner. Returns
# {"ok": bool, "first_failure": "name", "steps": [...]}.
#
# Local-docker workspaces (instance_id NULL) get diagnoseLocal which
# probes docker.Ping + container exec; we still expect ok=true there
# since local-docker is the alternative production path.
log "7b/11 Canvas-terminal EIC diagnose probe..."
for wid in $WS_TO_CHECK; do
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
if [ "$DIAG_OK" = "true" ]; then
ok " $wid terminal-reachable (canvas terminal will work)"
else
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
fi
done
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
log "8/11 Sending A2A message to parent — expecting agent response..."
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
@@ -510,6 +527,7 @@ fi
# "Encrypted content is not supported" → hermes codex_responses API misroute (#14)
# "Unknown provider" → bridge misconfigured PROVIDER= (regression of #13 fix)
# "hermes-agent unreachable" → gateway process died
# "exceeded your current quota" → MOLECULE_STAGING_OPENAI_KEY billing (NOT a platform regression — #2578)
#
# Fail LOUD with the specific pattern so CI log + alert channel makes the
# regression unambiguous.
@@ -535,6 +553,16 @@ fi
if echo "$AGENT_TEXT" | grep -qF "Invalid API key"; then
fail "A2A — REGRESSION: tenant auth chain returned 'Invalid API key'. Likely CP boot-event 401 race (CP #238) or stale OPENAI_API_KEY in the runtime env. Raw: $AGENT_TEXT"
fi
# Provider quota exhausted — distinguish from a platform regression so
# the canary alert names the operator action directly instead of falling
# through to the generic "error-shaped response" message. Steps 0-7 having
# passed means the platform itself is healthy (CP up, tenant provisioned,
# workspace online, A2A delivery end-to-end). When the agent comes back
# with a provider-side 429, that is a billing event on the configured
# OpenAI key, not a platform regression. Tracked in #2578.
if echo "$AGENT_TEXT" | grep -qiE "exceeded your current quota|insufficient_quota"; then
fail "A2A — PROVIDER QUOTA EXHAUSTED (NOT a platform regression). Operator action: top up MOLECULE_STAGING_OPENAI_KEY billing or rotate to a higher-quota org at Settings → Secrets and Variables → Actions. Tracked in #2578. Raw: $AGENT_TEXT"
fi
# Generic catch-all — falls through if none of the known regressions hit.
if echo "$AGENT_TEXT" | grep -qiE "error|exception"; then
fail "A2A returned an error-shaped response: $AGENT_TEXT"
@@ -83,7 +83,20 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
# Claude Code session. No tunnel/public URL needed (polling-based).
#
# 1. Save this token + workspace_id, then create ~/.claude/channels/molecule/.env:
# Prereq: Bun installed (channel plugins are Bun scripts).
# bun --version # must print a version number
#
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
# The plugin is NOT on Anthropic's default allowlist, so a one-time
# marketplace-add is needed before install:
#
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
# /plugin install molecule@molecule-mcp-claude-channel
#
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
#
# 2. Create the per-watched-workspace config file:
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
@@ -92,13 +105,32 @@ MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
EOF
chmod 600 ~/.claude/channels/molecule/.env
# 2. Launch Claude Code with the channel enabled:
claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
# allowlisted) channels need the --dangerously-load-development-channels
# flag to opt in — without it, you'll see "not on the approved channels
# allowlist" on startup.
claude --dangerously-load-development-channels \
--channels plugin:molecule@molecule-mcp-claude-channel
# You should see on stderr:
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
#
# Inbound A2A messages now surface as conversation turns. Claude's
# replies route back via the reply_to_workspace MCP tool — no extra
# wiring on your side.
#
# Common errors:
# "plugin not installed" → Step 1 didn't run; run /plugin install
# inside Claude Code, then /reload-plugins.
# "not on approved channels allowlist" → Add --dangerously-load-development-channels
# to the launch command (Step 3).
# "config-missing" → ~/.claude/channels/molecule/.env not
# readable; re-run Step 2 and check chmod.
#
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
# blocked by managed settings. Your admin must set channelsEnabled=true and
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
@@ -186,3 +218,191 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())
`
// externalHermesChannelTemplate — install snippet for operators whose
// external agent IS a hermes-agent session. Routes the workspace's
// A2A traffic into the running hermes gateway as platform messages
// via the molecule-channel plugin.
//
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
// platform adapter that:
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
// stdio MCP subprocess (separate from any hermes-side MCP
// client connection).
// 2. Long-polls ``wait_for_message`` on the platform's inbox.
// 3. Dispatches each inbound activity into the hermes gateway as a
// MessageEvent — same code path Telegram/Discord use.
// 4. Outbound replies route via ``send_message_to_user`` (canvas
// user) or ``delegate_task`` (peer agent) MCP tool calls.
//
// Result: hermes gets push parity with Claude Code / codex / openclaw —
// canvas messages and peer A2A arrive as conversation turns mid-session,
// not just at the start of a new ``hermes`` invocation.
//
// Plugin uses the upstream ``register_platform`` API shipped by
// NousResearch/hermes-agent#17751 (merged 2026-04-30) and falls back
// to the legacy ``register_platform_adapter`` shape on older forks —
// same wheel installs cleanly on stock or patched hermes-agent.
const externalHermesChannelTemplate = `# Hermes channel — bridges this workspace's A2A traffic into your
# hermes-agent session. No tunnel/public URL needed (long-poll based,
# same shape as the Claude Code channel).
#
# Prereq: a hermes-agent install on the target machine. Latest builds
# (post #17751) ship the platform-plugin API natively; older ones are
# also supported via the plugin's dual-mode fallback.
#
# 1. Install the runtime + plugin:
pip install molecule-ai-workspace-runtime
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
# 2. Export the workspace credentials:
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
export MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
export MOLECULE_WORKSPACE_TOKEN="<paste from create response>"
export MOLECULE_ORG_ID="<your org id>"
# 3. Edit ~/.hermes/config.yaml — under your existing top-level
# gateway: block, add a plugin_platforms entry:
#
# gateway:
# # ...your existing gateway settings...
# plugin_platforms:
# molecule:
# enabled: true
#
# If you don't yet have a gateway: block, create one with just
# that plugin_platforms entry. Don't append blindly — YAML
# rejects duplicate top-level keys, so a second gateway: block
# will silently break hermes config loading.
# 4. Restart the hermes gateway:
hermes gateway --replace
# Inbound canvas messages + peer A2A now arrive as MessageEvents —
# same dispatch path Telegram/Discord/Slack use. The agent replies via
# send_message_to_user / delegate_task MCP tool calls (already wired
# by the plugin's molecule_runtime MCP subprocess).
#
# Source + issue tracker:
# https://github.com/Molecule-AI/hermes-channel-molecule
`
// externalCodexTemplate — for operators whose external agent is a
// codex CLI (@openai/codex) session. Wires the molecule_runtime A2A
// MCP server into codex's config.toml so the agent can call
// list_peers / delegate_task / send_message_to_user / commit_memory.
//
// Push parity caveat: codex's MCP client doesn't forward arbitrary
// notifications/* from configured MCP servers (verified by reading
// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So
// this snippet gives outbound tools but NOT mid-turn push from
// inbound A2A. For full push parity on a codex external, the
// equivalent of hermes-channel-molecule would be needed — a bridge
// daemon that long-polls the platform inbox and calls codex's
// turn/steer RPC. Tracked separately; this snippet is the
// outbound-tool-only first cut.
const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external
# agent is a codex CLI (@openai/codex) session.
#
# This wires the molecule platform's A2A MCP server into codex so
# the agent can call list_peers / delegate_task / send_message_to_user
# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks)
# does NOT push into the running codex turn yet — codex's MCP runtime
# doesn't route arbitrary notifications/* from configured MCP servers.
# For inbound delivery into a codex session, pair with the Python SDK
# tab for now.
# 1. Install codex CLI + the workspace runtime wheel:
npm install -g @openai/codex@^0.57
pip install molecule-ai-workspace-runtime
# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}}
# and {{WORKSPACE_ID}} are stamped server-side; paste your auth
# token for MOLECULE_WORKSPACE_TOKEN before saving.
#
# Don't append blindly — TOML rejects duplicate
# [mcp_servers.molecule] tables, so re-running on an existing
# config will break codex parsing. If [mcp_servers.molecule]
# already exists (e.g. you set this up before), replace the
# existing block instead of appending.
mkdir -p ~/.codex
# (then open ~/.codex/config.toml in your editor and paste:)
#
# [mcp_servers.molecule]
# command = "python3"
# args = ["-m", "molecule_runtime.a2a_mcp_server"]
# startup_timeout_sec = 30
#
# [mcp_servers.molecule.env]
# WORKSPACE_ID = "{{WORKSPACE_ID}}"
# PLATFORM_URL = "{{PLATFORM_URL}}"
# MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"
# MOLECULE_ORG_ID = "<your org id>"
# 3. Run codex — the molecule tools are now available to the agent:
codex
`
// externalOpenClawTemplate — for operators whose external agent is an
// openclaw session. Wires the molecule MCP server via openclaw's
// `mcp set` config + starts the openclaw gateway on loopback.
//
// Like the codex tab, this is outbound-only. Full push parity on an
// external openclaw would need a sessions.steer bridge daemon (the
// equivalent of hermes-channel-molecule for openclaw). Tracked
// separately; outbound tools is the first cut.
const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose
# external agent is an openclaw session.
#
# This wires the molecule platform's A2A MCP server into openclaw's
# gateway so the agent can call list_peers / delegate_task /
# send_message_to_user / commit_memory. Inbound A2A push into a
# running openclaw run is not wired here yet — the platform-side
# openclaw template (template-openclaw) implements the full
# sessions.steer push path; an external setup would need the same
# bridge daemon the template uses. For inbound delivery on an
# external machine today, pair with the Python SDK tab.
# 1. Install openclaw CLI + the workspace runtime wheel:
npm install -g openclaw@latest
pip install molecule-ai-workspace-runtime
# 2. Onboard openclaw against your model provider (one-time setup).
# --non-interactive needs an explicit --provider + --model so it
# doesn't prompt; pick what matches your API key. Skip step 2 if
# you've already onboarded on this host.
#
# openclaw onboard --non-interactive \
# --provider openai \
# --model gpt-5
# 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}}
# are stamped server-side; paste the auth token before running.
WORKSPACE_TOKEN="<paste from create response>"
MOLECULE_ORG_ID="<your org id>"
openclaw mcp set molecule "$(cat <<EOF
{
"command": "python3",
"args": ["-m", "molecule_runtime.a2a_mcp_server"],
"env": {
"WORKSPACE_ID": "{{WORKSPACE_ID}}",
"PLATFORM_URL": "{{PLATFORM_URL}}",
"MOLECULE_WORKSPACE_TOKEN": "$WORKSPACE_TOKEN",
"MOLECULE_ORG_ID": "$MOLECULE_ORG_ID"
}
}
EOF
)"
# 4. Start the openclaw gateway as a durable background process.
# A bare '&' dies when the terminal closes; nohup + log file keeps
# the gateway alive across logout. For systemd-managed hosts,
# register a unit instead.
nohup openclaw gateway --dev --port 18789 --bind loopback \
> ~/.openclaw/gateway.log 2>&1 &
disown
# 5. Run an agent turn — molecule tools are now available:
openclaw agent --message "list my peers"
`
@@ -454,6 +454,41 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
// Hermes channel snippet — for operators whose external
// agent IS a hermes-agent session. Routes A2A traffic
// into the hermes gateway via the molecule-channel
// plugin (Molecule-AI/hermes-channel-molecule). Long-
// poll based (no tunnel) — same UX as the Claude Code
// channel tab. Gives hermes true push parity with the
// other runtime templates.
"hermes_channel_snippet": strings.ReplaceAll(
strings.ReplaceAll(externalHermesChannelTemplate,
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
// Codex MCP config snippet — for operators whose
// external agent is a codex CLI (@openai/codex)
// session. Wires the molecule MCP server into
// ~/.codex/config.toml. Outbound-tools-only today;
// codex's MCP client doesn't route arbitrary
// notifications/* so push parity needs a separate
// bridge daemon (future work).
"codex_snippet": strings.ReplaceAll(
strings.ReplaceAll(externalCodexTemplate,
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
// OpenClaw MCP config snippet — for operators whose
// external agent is an openclaw session. Wires the
// molecule MCP server via `openclaw mcp set` + starts
// the gateway on loopback. Outbound-tools-only today;
// full push parity needs a sessions.steer bridge
// daemon (future work).
"openclaw_snippet": strings.ReplaceAll(
strings.ReplaceAll(externalOpenClawTemplate,
"{{PLATFORM_URL}}", platformURL),
"{{WORKSPACE_ID}}", id,
),
}
}
c.JSON(http.StatusCreated, resp)
@@ -0,0 +1,7 @@
-- Reverse 048_activity_logs_peer_indexes.up.sql.
-- Drops the partial peer-conversation indexes added there.
-- chat_history queries fall back to the existing idx_activity_ws_type_time
-- + workspace-scoped seq scan / filter on the OR clause.
DROP INDEX IF EXISTS idx_activity_ws_target;
DROP INDEX IF EXISTS idx_activity_ws_source;
@@ -0,0 +1,42 @@
-- Add per-peer indexes on activity_logs to make chat_history queries
-- index-driven instead of seq-scan-driven on workspaces with thousands
-- of accumulated rows. #2478.
--
-- chat_history hits:
--
-- SELECT ... FROM activity_logs
-- WHERE workspace_id = $1
-- AND activity_type = 'a2a_receive'
-- AND (source_id = $2 OR target_id = $2)
-- ORDER BY created_at DESC LIMIT 20;
--
-- The existing idx_activity_ws_type_time covers workspace_id+type
-- prefix but the (source_id = $X OR target_id = $X) clause then forces
-- a workspace-scoped seq-scan-and-filter. Two separate indexes (one per
-- nullable column) let Postgres BitmapOr them into a workspace-scoped
-- BitmapAnd against the existing index.
--
-- Partial WHERE NOT NULL because most activity rows (heartbeats,
-- agent_log, memory_write, etc.) have NULL source_id/target_id and
-- shouldn't bloat the index. Per-row index size drops from ~all rows
-- to ~A2A-only rows.
--
-- Anti-pattern caveat from the issue: a single compound (a, b) index
-- can't serve `a OR b` — Postgres can only use compound for prefix
-- match. Two separate indexes + BitmapOr is the right shape.
--
-- CONCURRENTLY would be ideal for online deploys, but goose runs
-- migrations in a single transaction by default which doesn't allow
-- CONCURRENTLY. The alternative (annotating the migration to skip the
-- transaction wrapper) is a per-runner concern; leaving as plain
-- CREATE INDEX so this works under any goose config. activity_logs is
-- typically <O(100k) rows per workspace × <O(100) workspaces in
-- production today; the lock is sub-second-scale.
CREATE INDEX IF NOT EXISTS idx_activity_ws_source
ON activity_logs(workspace_id, source_id)
WHERE source_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_activity_ws_target
ON activity_logs(workspace_id, target_id)
WHERE target_id IS NOT NULL;
+69 -1
View File
@@ -458,16 +458,84 @@ def _build_channel_notification(msg: dict) -> dict:
# endpoint to hit for capabilities lookup.
meta["agent_card_url"] = _agent_card_url_for(safe_peer_id)
# Compose the conversation-turn text Claude actually sees. Header
# carries peer identity (name + role when registry-resolved, peer_id
# always); footer carries the exact reply-tool call shape so the
# model doesn't have to remember which tool to call or what args to
# pass. See _format_channel_content for the rationale + tradeoff on
# coupling display to behaviour. Mirrors the change shipped for the
# external channel-plugin path
# (Molecule-AI/molecule-mcp-claude-channel#24); the universal MCP
# path is the same display surface for in-workspace agents.
content = _format_channel_content(
text=msg.get("text", ""),
kind=meta["kind"],
peer_id=meta["peer_id"],
peer_name=meta.get("peer_name"),
peer_role=meta.get("peer_role"),
)
return {
"jsonrpc": "2.0",
"method": _CHANNEL_NOTIFICATION_METHOD,
"params": {
"content": msg.get("text", ""),
"content": content,
"meta": meta,
},
}
def _format_channel_content(
*,
text: str,
kind: str,
peer_id: str,
peer_name: str | None = None,
peer_role: str | None = None,
) -> str:
"""Prepend identity + append reply-tool example to the inbound text.
Why this couples display to behaviour: Claude Code surfaces the
notification's ``content`` as the conversation turn. Without context
in the text, the model has to remember (a) who sent the message,
(b) which tool to call to reply, (c) which args to pass. Putting it
in the turn itself makes the reply path self-documenting at the
cost of ~80 extra chars per push.
The reply-tool names live in the same module as the notification
builder so the ``feedback_doc_tool_alignment`` drift class can't bite:
a future tool-rename PR that misses this hint would also fail
``test_format_channel_content_*`` below.
canvas_user → ``send_message_to_user({message: "..."})`` — pushed via
canvas WebSocket, lands in the user's chat panel.
peer_agent → ``delegate_task({workspace_id: peer_id, task: "..."})``
— sends an A2A reply to the calling peer.
"""
if kind == "canvas_user":
header = "[from canvas user]"
hint = '↩ Reply: send_message_to_user({message: "..."})'
elif kind == "peer_agent":
if peer_name and peer_role:
identity = f"{peer_name} ({peer_role})"
elif peer_name:
identity = peer_name
else:
identity = "peer-agent"
header = f"[from {identity} · peer_id={peer_id}]"
hint = (
f'↩ Reply: delegate_task({{workspace_id: "{peer_id}", '
f'task: "..."}})'
)
else:
# Defensive default — _safe_meta_field already constrains kind to
# _VALID_KINDS, so this branch is unreachable in practice. Emit
# the bare text rather than crash so a future kind value (added
# to the allowlist but not the formatter) degrades gracefully
# instead of breaking every push.
return text
return f"{header}\n{text}\n{hint}"
# --- MCP Server (JSON-RPC over stdio) ---
+153 -6
View File
@@ -171,9 +171,19 @@ def test_build_channel_notification_method_matches_claude_contract():
assert payload["jsonrpc"] == "2.0"
def test_build_channel_notification_content_is_message_text():
"""`content` is what becomes the agent conversation turn —
pulled directly from the inbox message text."""
def test_build_channel_notification_content_wraps_text_with_identity_and_reply_hint():
"""`content` is what becomes the agent conversation turn — wrapped
with an identity header AND a reply-tool hint. The wrapping makes the
reply path self-documenting so the agent doesn't have to remember
which platform tool to call (per the cross-codepath fix shipped with
Molecule-AI/molecule-mcp-claude-channel#24).
Before this change `content == msg["text"]` and the agent had to
reach into meta + recall send_message_to_user / delegate_task on
every push. Now the conversation turn carries the identity inline
and a copy-pasteable reply call, so the model surfaces the right
routing without round-tripping through tool documentation each time.
"""
from a2a_mcp_server import _build_channel_notification
payload = _build_channel_notification({
@@ -185,7 +195,14 @@ def test_build_channel_notification_content_is_message_text():
"created_at": "2026-05-01T00:00:00Z",
})
assert payload["params"]["content"] == "hello from canvas"
# Exact match — per `feedback_assert_exact_not_substring`, substring
# asserts pass for both correct formatting AND for "raw input echoed"
# regression. Only equality discriminates.
assert payload["params"]["content"] == (
"[from canvas user]\n"
"hello from canvas\n"
'↩ Reply: send_message_to_user({message: "..."})'
)
def test_build_channel_notification_meta_carries_routing_fields():
@@ -235,7 +252,14 @@ def test_build_channel_notification_no_id_field():
def test_build_channel_notification_handles_missing_fields_gracefully():
"""Some fields may be absent on edge-case messages (e.g. cursor
bootstrapping with no created_at yet). Default to empty strings
so the wire shape stays valid JSON instead of crashing."""
so the wire shape stays valid JSON instead of crashing.
With an empty-kind payload the formatter falls through its
defensive default branch (kind not in _VALID_KINDS) and emits the
bare text — no header, no reply hint. This degrades gracefully
rather than emitting a "[from None]" header that would mislead the
receiving agent about who sent the empty payload.
"""
from a2a_mcp_server import _build_channel_notification
payload = _build_channel_notification({})
@@ -247,6 +271,120 @@ def test_build_channel_notification_handles_missing_fields_gracefully():
assert meta["kind"] == ""
# ----- _format_channel_content: identity header + reply-tool hint ----------
#
# Pinned separately from _build_channel_notification so a regression in
# the formatter surfaces with a tight failure message ("expected
# delegate_task hint, got send_message_to_user") rather than buried in a
# generic envelope-shape diff. Per `feedback_assert_exact_not_substring`,
# all asserts pin exact strings.
def test_format_channel_content_canvas_user_uses_send_message_to_user():
"""canvas_user → reply via send_message_to_user (canvas WebSocket
push). Header omits peer_id since canvas messages don't carry one."""
from a2a_mcp_server import _format_channel_content
out = _format_channel_content(
text="what's the deploy status?",
kind="canvas_user",
peer_id="",
)
assert out == (
"[from canvas user]\n"
"what's the deploy status?\n"
'↩ Reply: send_message_to_user({message: "..."})'
)
def test_format_channel_content_peer_agent_with_full_enrichment():
"""peer_agent + name + role → friendly identity, delegate_task hint
with workspace_id arg pinned to the peer's UUID."""
from a2a_mcp_server import _format_channel_content
peer_uuid = "11111111-2222-3333-4444-555555555555"
out = _format_channel_content(
text="ping",
kind="peer_agent",
peer_id=peer_uuid,
peer_name="ops-agent",
peer_role="sre",
)
assert out == (
f"[from ops-agent (sre) · peer_id={peer_uuid}]\n"
"ping\n"
f'↩ Reply: delegate_task({{workspace_id: "{peer_uuid}", task: "..."}})'
)
def test_format_channel_content_peer_agent_name_only():
"""peer_agent + name (no role) → identity uses bare name. Catches
the regression where role-only or both-missing branches accidentally
print 'None' or '(undefined)' in the header."""
from a2a_mcp_server import _format_channel_content
peer_uuid = "11111111-2222-3333-4444-555555555555"
out = _format_channel_content(
text="ping",
kind="peer_agent",
peer_id=peer_uuid,
peer_name="ops-agent",
)
assert out.startswith(f"[from ops-agent · peer_id={peer_uuid}]\n")
assert "(None)" not in out
assert "(undefined)" not in out
def test_format_channel_content_peer_agent_no_enrichment_falls_back():
"""peer_agent without name/role (registry miss) → identity is
'peer-agent' and peer_id is still surfaced so the reply call has
a value to copy."""
from a2a_mcp_server import _format_channel_content
peer_uuid = "11111111-2222-3333-4444-555555555555"
out = _format_channel_content(
text="ping",
kind="peer_agent",
peer_id=peer_uuid,
)
assert out == (
f"[from peer-agent · peer_id={peer_uuid}]\n"
"ping\n"
f'↩ Reply: delegate_task({{workspace_id: "{peer_uuid}", task: "..."}})'
)
def test_format_channel_content_unknown_kind_degrades_to_raw_text():
"""Defensive default — _safe_meta_field already constrains kind to
_VALID_KINDS, so this branch is unreachable in practice. But if a
future kind is added to the allowlist before the formatter learns
about it, emitting raw text is better than crashing the push path."""
from a2a_mcp_server import _format_channel_content
assert _format_channel_content(
text="something", kind="future_kind", peer_id="",
) == "something"
def test_format_channel_content_preserves_multiline_text():
"""Body text may contain newlines (multi-paragraph user prose,
code blocks). Content composition must not collapse or truncate
them — the agent's reply quality depends on seeing the full
inbound message."""
from a2a_mcp_server import _format_channel_content
multi = "first paragraph\n\nsecond paragraph\nstill second"
out = _format_channel_content(
text=multi, kind="canvas_user", peer_id="",
)
# Body sandwiched between header and hint, separated by single
# newlines. Body itself unchanged.
assert (
f"[from canvas user]\n{multi}\n"
'↩ Reply: send_message_to_user({message: "..."})'
) == out
# ----- Channel envelope enrichment (peer_name / peer_role / agent_card_url) ---
#
# The bare envelope only carries `peer_id` for peer_agent inbound, so the
@@ -1088,7 +1226,16 @@ async def test_inbox_bridge_emits_channel_notification_to_writer():
assert payload["jsonrpc"] == "2.0"
assert payload["method"] == "notifications/claude/channel"
assert payload["params"]["content"] == "hello from peer"
# Content is wrapped with the identity header + reply hint —
# see _format_channel_content. The bridge test pins the full
# composition so a regression to "raw text only" surfaces here
# as well as in the per-formatter tests above.
assert payload["params"]["content"] == (
"[from peer-agent · peer_id=11111111-2222-3333-4444-555555555555]\n"
"hello from peer\n"
'↩ Reply: delegate_task({workspace_id: '
'"11111111-2222-3333-4444-555555555555", task: "..."})'
)
meta = payload["params"]["meta"]
assert meta["source"] == "molecule"
assert meta["kind"] == "peer_agent"