Commit Graph

518 Commits

Author SHA1 Message Date
Hongming Wang
4c9309e801 fix(canvas/chat): tag scroll anchor + token-guard fetches (race fixes)
Multi-model review of #2826 found two issues my self-approval missed:

C1. Live agent-message append during loadOlder() yanked scroll AND
    swallowed the bottom-pin. The useLayoutEffect's "restore against
    saved distance-from-bottom" branch fired on ANY messages update
    while scrollAnchorRef was set — including appends from agent pushes
    that landed mid-fetch. User reading mid-history got thrown to a
    stale offset; the new agent message's normal scroll-to-bottom was
    silently swallowed.

    Fix: tag scrollAnchorRef with `expectFirstIdNotEqual` (the oldest
    message's id BEFORE the prepend). The layout effect only honors
    the anchor when messages[0].id has changed from that tag — i.e.,
    a real prepend happened, not an append.

R4. Workspace switch mid-fetch leaked the in-flight promise's result
    into the new workspace's state — user briefly saw someone else's
    history. Same shape for a fast-clicked Retry button or rapid
    scroll-flick triggering a second loadOlder.

    Fix: `fetchTokenRef` monotonic counter. loadInitial + loadOlder
    each capture their token at entry; the .then() bails if the
    token has moved. Both call sites bump the token at fetch start
    so any in-flight stale fetch loses identity.

C2 (loadOlder identity stability via refs) and R3 (inflightRef
synchronous double-entry guard) were already pushed in the previous
commit on this branch.

Build + 1258 tests pass.
2026-05-04 20:42:29 -07:00
Hongming Wang
20f76c4fdf fix(canvas/chat): stable IntersectionObserver + inflight guard for loadOlder
Self-review of the lazy-load PR caught three Important findings:

1. IO observer was re-armed on every messages change. The previous
   loadOlder useCallback depended on `messages`, so every live agent
   push recreated it → re-ran the IO useEffect → tore down + re-armed
   the observer. In a perf PR shipping to chat-heavy users, that's
   the wrong direction. Fix: refs for the captured state
   (oldestMessageRef, hasMoreRef), narrow loadOlder deps to
   [workspaceId], and gate the IO effect on `messages.length > 0`
   (boolean) instead of `messages` so it arms exactly once when data
   first lands and stays armed across appends.

2. loadingOlder setState race. Two IO callbacks dispatched in the
   same microtask (fast scroll, layout shift) could both pass the
   `if (loadingOlder)` guard before React committed setLoadingOlder.
   Fix: synchronous inflightRef set BEFORE any await, cleared in
   finally; loadingOlder state stays for the UI label only.

3. Retry-button onClick duplicated the mount-effect body. Single
   loadInitial() callback now serves both, eliminating the drift
   hazard.

Coverage:
- 4 new tests bring the file to 8/8 (was 4):
  - loadOlder fetches with limit=20 and before_ts=oldest.timestamp
  - inflight guard rejects three concurrent IO triggers while a
    deferred fetch is in flight (asserts call count stays at 2,
    not 5)
  - empty older response unmounts the sentinel (proxy for the
    anchor-clearing branch in loadOlder)
  - IO observer instance survives three subsequent prepends — same
    object reference both before and after, no churn
- Both behavioural tests verified to FAIL on the prior code
  (stashed ChatTab.tsx, ran them alone, confirmed both red), then
  PASS on this commit. Pinning real regressions, not tautologies.
- IntersectionObserver fake captures instances + exposes
  triggerIntersection() so the IO callback can be driven directly
  from jsdom (no real layout / scrolling needed).

Test: vitest run src/components/tabs/__tests__/ → 39 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:38:37 -07:00
Hongming Wang
ba63f76e10 feat(canvas/terminal): not-available banner for runtimes without a TTY
Pre-fix TerminalTab tried to open /ws/terminal/<id> for every workspace
including external ones (which have no shell endpoint on the
workspace-server). The server returned 404, status flipped to "error",
the user saw "Connection failed" with a Reconnect button — reading as
a bug when really the runtime intentionally has no TTY.

Now: when data.runtime is in RUNTIMES_WITHOUT_TERMINAL (currently just
"external"), TerminalTab renders a NotAvailablePanel with a big
terminal-off icon and a one-line explanation including the runtime
name. The xterm + WebSocket dance is skipped entirely — no spurious
404s, no scary error UI, no Reconnect that can't help.

The runtime is determined from the data prop now threaded by
SidePanel.tsx (existing pattern for ChatTab/ConfigTab/etc).

Tests: 4 new in TerminalTab.notAvailable.test.tsx pin: external
renders banner with runtime name, external doesn't open WS, claude-
code mounts normally (regression cover for the early-return scope),
data omitted falls through (back-compat).

Build clean. 1258 tests pass.
2026-05-04 20:33:13 -07:00
Hongming Wang
8152cfc81e feat(canvas/chat): lazy-load history — 10 newest on mount, 20 per scroll-up batch
Pre-fix ChatTab fetched the newest 50 messages on every mount and
scrolled to bottom, paying full DOM cost up-front even when the user
only wanted to read the last few bubbles. On a long-running workspace
this meant 50× message-bubble paint + DOM cost on every tab swap.

Now:
  - Initial fetch limit=10 (newest-first slice).
  - IntersectionObserver on a top sentinel (rootMargin 200px) fires
    loadOlder() the moment the user scrolls within 200px of the top.
  - loadOlder() uses the oldest loaded message's timestamp as
    `before_ts` (RFC3339 cursor the /activity endpoint already
    supports) and fetches OLDER_HISTORY_BATCH (20) more.
  - hasMore turns false when the server returns < limit rows; the
    sentinel unmounts and the IO observer disconnects — no spinner
    on a short conversation.
  - useLayoutEffect handles scroll behavior across messages updates:
    a prepend (loadOlder landed) restores the user's saved
    distance-from-bottom (captured via scrollAnchorRef before the
    fetch) so their reading position doesn't jump; an append /
    initial load pins to the latest bubble.

Tests: 4 new in ChatTab.lazyHistory.test.tsx pinning the limit=10
on initial fetch, hasMore=false on short-history, full-page rendering
on exactly-the-limit, and limit=10 on retry-after-failure. Doesn't
exercise the IO/scroll-anchor in jsdom — that's brittler than
trusting the synth-canary against a live tenant.

Build clean. Existing 1250 tests + 4 new = 1254 pass.
2026-05-04 20:12:01 -07:00
Hongming Wang
beab899501 feat(ConfigTab): drop Skills/Tools tag inputs, give Prompt Files its own section
User feedback (2026-05-04 conversation):
> "Skills and Tools are having their own tab as plugin, and Prompt
> Files are in the file system which can be directly edited. Am I
> missing something?"
> "Tools should be merged into plugin then, and for prompt files... it
> should be in another section than in skill& tools"

The "Skills & Tools" section in ConfigTab had three TagList inputs:
  - Skills:       managed via the dedicated SkillsTab (per-workspace
                  skill folders) — duplicate UI affordance
  - Tools:        managed via the Plugins tab (install a plugin → its
                  tools become available) — duplicate UI affordance
  - Prompt Files: load order for system-prompt files — semantically
                  unrelated to skills/tools

Drop the Skills + Tools inputs. Move Prompt Files into its own
section with explanatory copy that names the auto-loaded files
(system-prompt.md, CLAUDE.md, AGENTS.md) and points users at the
Files tab for actual editing.

Schema fields `config.skills` and `config.tools` are KEPT (load-bearing
for runtime skill loading + tool registry); only the inline editor goes
away. Operators who need to edit them can still use the Raw YAML toggle.

Tests:
- New ConfigTab.sections.test.tsx with 4 cases:
  1. "Skills & Tools" section title is gone
  2. Skills tag input is absent
  3. Tools tag input is absent
  4. Prompt Files section exists with explanatory copy

Sibling ConfigTab tests (hermes, provider) all still pass (20/20).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:02:05 -07:00
Hongming Wang
4ea6f437e9 feat(external-templates): codex tab now includes the bridge-daemon inbound path
The codex tab in the External Connect modal had a "outbound-tools-only
first cut" caveat — operators got the MCP wiring for codex calling
platform tools, but there was no documented inbound path. Canvas
messages couldn't wake an idle codex session.

That gap is now filled by codex-channel-molecule
(github.com/Molecule-AI/codex-channel-molecule), shipped today as the
codex counterpart to hermes-channel-molecule. The daemon long-polls
the platform inbox, runs `codex exec --resume <session>` per inbound
message, captures the assistant reply, routes it back via
send_message_to_user / delegate_task, and acks the inbox row.
Per-thread session continuity persisted to disk so daemon restarts
don't lose conversation context.

This commit:
- Updates externalCodexTemplate to include `pip install
  codex-channel-molecule` (step 1) and a foreground `nohup
  codex-channel-molecule` invocation (step 3) using the same env-var
  contract as the MCP server (WORKSPACE_ID + PLATFORM_URL +
  MOLECULE_WORKSPACE_TOKEN).
- Adds a "Canvas messages don't wake codex" common-issues entry to the
  TAB_HELP codex section pointing at the bridge daemon log.
- Updates the doc comment to record the upstream deprecation path:
  when openai/codex#17543 lands, the bridge becomes redundant and the
  wired MCP server delivers push natively.

Verified: TestExternalTemplates_NoMoleculeOrgIDPlaceholder still
passes (no MOLECULE_ORG_ID re-introduction); full handlers suite
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:28:35 -07:00
Hongming Wang
5559e96400 Merge branch 'temp-staging' into try-merge
# Conflicts:
#	tests/e2e/test_staging_full_saas.sh
2026-05-04 16:34:55 -07:00
Hongming Wang
2f7beb9bce feat: drop shared_context — use memory v2 team namespace instead
Parent → child knowledge sharing previously lived behind a `shared_context`
list in config.yaml: at boot, every child workspace HTTP-fetched its parent's
listed files via GET /workspaces/:id/shared-context and prepended them as
a "## Parent Context" block. That paid the full transfer cost on every
boot regardless of whether the agent needed it, single-parent SPOF, no team
or org scope, and broken if the parent was unreachable.

Replace with memory v2's team:<id> namespace: agents call recall_memory
on demand. For large blob-shaped artefacts see RFC #2789 (platform-owned
shared file storage).

Removed:
- workspace/coordinator.py: get_parent_context()
- workspace/prompt.py: parent_context arg + injection block
- workspace/adapter_base.py: import + call + arg pass
- workspace/config.py: shared_context field + parser entry
- workspace-server/internal/handlers/templates.go: SharedContext handler
- workspace-server/internal/router/router.go: GET /shared-context route
- canvas/src/components/tabs/ConfigTab.tsx: Shared Context tag input
- canvas/src/components/tabs/config/form-inputs.tsx: schema field + default
- canvas/src/components/tabs/config/yaml-utils.ts: serializer entry
- 6 tests pinning the removed behavior; 5 doc references

Added regression gates so any reintroduction is loud:
- workspace/tests/test_prompt.py: build_system_prompt must NOT emit
  "## Parent Context"
- workspace/tests/test_config.py: legacy YAML key loads cleanly but
  shared_context attr must NOT exist on WorkspaceConfig
- tests/e2e/test_staging_full_saas.sh §9d: GET /shared-context must NOT
  return 200 against a live tenant

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:30:26 -07:00
Hongming Wang
3a5544a9e6 feat(memory tab): add Edit affordance with optimistic-locking
Memory tab supported only Add+Delete. Correcting an entry meant
deleting and re-adding, losing the row's version counter and any
concurrent-write guard the agent depends on.

Now: per-row Edit button reveals an inline editor (value textarea +
TTL). Save POSTs to the existing /memory upsert endpoint with
if_match_version pinned to the entry's current version. On 409 the
UI surfaces a retry hint and reloads.

Tests:
- 11 vitest cases covering pre-fill (JSON vs string), payload shape
  (parsed JSON, fallback to plain text, TTL inclusion/omission),
  cancel, 409 retry path, generic error path, and the no-version
  back-compat case.
- E2E gate 9c in test_staging_full_saas.sh: seed → GET version →
  conditional update → assert new value → stale-version POST must
  409. Pins the optimistic-locking contract end-to-end on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:18:08 -07:00
Hongming Wang
5c80b9c3d6 feat(canvas): render misconfigured workspaces with the configuration_status from agent_card
Closes molecule-controlplane#467 (issue filed against CP, but resolution
landed canvas-side because the workspace-server ALREADY returns the
agent_card JSONB blob with configuration_status / configuration_error
fields populated by molecule-core PR #2756). No CP-side change needed —
the gap was the canvas's blindness to those fields.

Before this PR, a workspace whose adapter.setup() failed (typically
missing/rotated LLM credential) appeared identical to a healthy one in
the canvas tile: green "Online" status, no error indication. The
operator had to dig into workspace logs to discover the env var to set.

This PR surfaces the state via the existing status-pill UX:

1. STATUS_CONFIG gains a "not_configured" entry — amber dot/glow,
   "Not configured" label. Distinct from "online" (emerald) and
   "failed" (red) — the workspace is reachable, it just needs config.

2. canvas-topology exposes getConfigurationStatus / getConfigurationError
   helpers — strict equality on the JSONB field so unknown values
   pass through as null instead of crashing the tile renderer.

3. WorkspaceNode derives an `effectiveStatus` that overrides
   data.status with "not_configured" when (status === "online" AND
   agent_card.configuration_status === "not_configured"). The override
   only applies on top of "online" — a genuinely offline / failed /
   provisioning workspace keeps its existing treatment.

4. The configuration_error string surfaces in two places: the tile's
   aria-label (screen reader access) + a truncated preview row at the
   bottom of the tile (same visual as the existing "degraded error
   preview" — mirrors the established pattern for in-tile error
   surfacing).

Test coverage: 11 new in canvas-topology-configuration-status.test.ts.
Each helper covered for the happy path, missing fields, defensive
ignores of unknown values, and an end-to-end "stale ready overrides
old error" guard.

Once this lands + canvas redeploys, operators see "Not configured:
Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set" right on the
workspace tile instead of a confused-looking green "online" workspace
that silently 503s every JSON-RPC request.

Pairs with: molecule-core PR #2756 (decouple agent-card from setup),
            #2775 (boot_routes pin), #2778 (secret_redactor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:14:40 -07:00
Hongming Wang
e1c99cd24c Pin the visibility gate behavior, not just cadence
Self-review on PR #2723 caught a coverage gap: the existing
"visibility gate" describe block actually tested cadence (10s/30s
timing), not the gate itself. If a refactor dropped the
`if (!visible) return` line, the cadence test would still pass
because the effect would still fire every 30s — the regression would
silently ship.

New test renders with comms-returning mock so the panel renders, clicks
the close button, advances 60s, asserts no further fetches occur.

Discipline-verified: removed `if (!visible) return` from the source,
test fails as expected. Restored, test passes.

Same failure mode as PR #434 (test asserted broken behavior) — pin
what you claim to fix, not the easy substring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 03:18:42 -07:00
Hongming Wang
26b5b21238 Fix CommunicationOverlay rate-limit storm: cap fan-out + gate on visibility
User report 2026-05-04: 8+ workspace tenant (Design Director + 6 sub-agents
+ 3 standalones) saw sustained 429s in canvas console hitting
/workspaces/<id>/activity?limit=5. Server-side rate limit is 600 req/min/IP.

Three compounding issues in CommunicationOverlay:
1. Polled regardless of visibility — collapsed panel still hammered the API
2. 10s cadence — 6 req every 10s = 36 req/min from this overlay alone
3. Fan-out cap of 6 workspaces — scaled linearly with workspace count

Fix:
- Gate setInterval on `visible` (effect re-runs when collapsed/expanded)
- Cadence 10s → 30s
- Fan-out cap 6 → 3

Combined: ~36 req/min worst case → 6 req/min worst case (6x reduction),
0 req/min when collapsed.

Tests:
- Fan-out cap: 6 online nodes mounted → exactly 3 fetches (was 6)
- Offline gate: offline workspace never polled
- Cadence: timer at 10s = no new fetch; timer at 30s = next batch fires

Each test would fail if the corresponding dial regressed.

Follow-up (out of scope): structurally right fix is to consume the
WORKSPACE_ACTIVITY WS broadcast instead of polling per-workspace. Server
already publishes the events; canvas just isn't subscribing yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 03:18:42 -07:00
Hongming Wang
32fc77bad4 fix(canvas): A2ATopologyOverlay re-fetch storm hammering /activity → 429
Selector instability caused fetchAndUpdate to recreate on every Zustand
nodes[] mutation (status flips, position drags, peer-discovery writes,
heartbeats — typically ~5/sec). Each recreation invalidated the
useEffect deps so the 60s polling fan-out fired on every update,
hammering /workspaces/<id>/activity?type=delegation 5×N requests/sec
until the edge rate-limit returned 429. User-reported via browser
console showing infinite uE→ux→uE→ux render loop and 429s repeating
across every visible workspace ID.

Root cause:
  const nodes = useCanvasStore((s) => s.nodes);
  const visibleIds = useMemo(() => nodes.filter(...).map(...), [nodes]);
  // useMemo dep recreates on every store update, even when ID set unchanged

Fix: select a STABLE STRING KEY (sorted CSV of visible IDs) from
Zustand. The selector's shallow-equal short-circuit prevents re-renders
when the actual visible-ID set is unchanged, so visibleIds reference
stays stable, fetchAndUpdate keeps its identity, and the useEffect
only re-fires when the visible-ID-set genuinely changes.

Tests:
- New regression test "does not re-fetch when nodes[] reference
  changes but visible IDs are the same"
- Discipline-verified: pre-fix code emits 4 fetches (2 mount + 2
  re-fetch storm), post-fix emits exactly 2
- Companion test "re-fetches when the visible ID set actually changes"
  pins the desired behavior so future "stabilization" doesn't suppress
  legitimate updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:39:36 -07: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
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
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
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
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
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
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
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
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
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
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
2f89a05f2f
Merge branch 'staging' into a11y/canvas-delete-cascade-buttons 2026-05-03 17:55:48 -07: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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
a4a32cded5 fix(canvas): WorkspaceNode + tier-config contrast in light theme
Cards on the canvas had multiple low-contrast surfaces in light mode:

WorkspaceNode.tsx (parent + TeamMemberChip) — same fixes both copies:
- "N sub" badge: hardcoded text-violet-300 + bg-violet-900/40 → semantic
  text-accent + bg-accent/15 + border-accent/40 (themes correctly).
- "REMOTE" pill: hardcoded violet/40 alpha → solid bg-violet-600 text-white
  (works on either surface with WCAG AA contrast).
- Runtime pill: drop /60 + /30 alpha modifiers, use solid surface-card +
  border-line tokens.
- Skill chips (online): text-good/80 + bg-emerald-950/30 (washed-out on
  warm-paper) → text-good + bg-good/15 + border-good/40 semantic.
- Skill chips (offline): text-ink-mid + bg-surface-card without alpha.
- Restart-to-apply banner: bg-sky-950/30 + text-sky-300/80 → bg-accent/10 +
  text-accent (sky-950 was nearly invisible on cream).
- Provisioning status text: text-sky-400 (poor on cream) → text-accent.
- "+N more" badges: text-ink-soft (3.5:1) → text-ink-mid (7:1).
- Active-tasks dot: bg-amber-400 + text-warm/80 → semantic bg-warm + text-warm.
- Degraded error preview: bg-amber-950/20 + text-warm/60 → bg-warm/10 +
  text-warm + border-warm/40.
- Eject icon hover: hover:text-sky-400 → hover:text-accent.
- Role text: text-ink-soft → text-ink-mid.

design-tokens.ts:
- TIER_CONFIG was dark-only: T2 (text-sky-400 + bg-sky-950/50), T3
  (text-violet-400 + bg-violet-950/50), T4 (text-warm + bg-amber-950/50).
  Migrated to solid bg + white text patterns: T2=accent, T3=violet-600,
  T4=warm. T1 stays neutral (surface-card + ink-mid). All four pass WCAG
  AA on either theme.

No globals.css changes; uses existing semantic tokens.
2026-05-03 10:28:49 -07:00
Hongming Wang
3d145da99d fix(canvas): chat bubble + sub-tab contrast in light theme
Chat bubble fixes (canvas/src/components/tabs/ChatTab.tsx):
- User bubble: bg-accent-strong/30 + text-blue-100 → bg-accent + text-white
  (translucent dark-blue overlay on warm-paper surface read as pale lavender
  with near-invisible light-blue text — a real WCAG AA failure on the
  highest-traffic surface in canvas).
- System/error bubble: bg-red-900/30 + text-red-200 → bg-bad/10 + text-bad,
  using semantic tokens so dark-mode adapts automatically.
- Agent bubble: drop /80 + /30 opacity modifiers; solid bg-surface-card +
  text-ink + border-line gives consistent contrast in both themes.
- prose-invert was unconditional, so markdown text on agent/system bubbles
  rendered as light text on a light surface in light mode. Make it apply
  only on the user bubble (the only dark surface in this component).
- Timestamp: text-ink-soft is too pale on warm-paper; use text-ink-mid for
  agent/system, white/70 for user (visible on the now-solid blue bg).

Sub-tab bar fixes (canvas/src/components/SidePanel.tsx):
- Right-edge fade was hardcoded `from-zinc-950` — that paints a dark vertical
  strip on the right edge of the tab bar in light mode. Switch to
  `from-surface` so the gradient blends into whichever theme is active.
- Inactive tab text: text-ink-soft (~3.5:1 on warm-paper) → text-ink-mid
  (~7:1). Active tab background: drop the /40 opacity so the selection is
  unambiguous on either surface.

No semantic-token additions; all changes use existing warm-paper tokens
that already work in both themes.
2026-05-03 09:58:18 -07:00
Hongming Wang
e2328abedc fix(canvas): add role=status + aria-live to remaining loading states
Three loading-state divs were missing the role/aria pattern that
TemplatePalette.tsx and EmptyState.tsx already follow. Screen readers
get no signal that the page is waiting:

- canvas/src/app/page.tsx — full-screen "Loading canvas..." while
  the websocket hydrates. First paint of the entire app.
- canvas/src/components/settings/TokensTab.tsx — "Loading tokens..."
- canvas/src/components/settings/OrgTokensTab.tsx — "Loading keys..."

Add role="status" + aria-live="polite" to the wrapping div so
assistive tech announces the wait and the eventual transition.
Visual rendering unchanged.
2026-05-03 07:11:48 -07:00
Hongming Wang
7abb94dab8 fix(canvas): align tier text contracts with 4-tier reality (T1/T2/T3/T4)
The tier system in CreateWorkspaceDialog and design-tokens has been
T1 Sandboxed / T2 Standard / T3 Privileged / T4 Full Access, but two
chrome surfaces still showed the older 3-tier mapping with T3 as
"Full Access":

- Legend (bottom-left chrome on every canvas page) listed only T1/T2/T3
  and called T3 "Full Access". On a SaaS tenant the actual workspace
  badges render T4 (in amber/warm) — there was no T4 entry in the
  legend at all, so the user sees an undocumented orange badge.

- ConfigTab tier dropdown (per-workspace settings → Sandboxing) had no
  T4 option at all and called T3 "Full Access". So an existing T4
  workspace would show "T3 — Full Access" as the selected option,
  silently downgrading the displayed tier on the settings panel.

- tenant.ts isSaaSTenant() doc comment claimed SaaS workspaces are
  "inherently T3 Full Access" — wrong on both the number and the lock
  rationale (SaaS hides T1/T2/T3, not just T1/T2).

Fix:
- Legend now imports TIER_CONFIG and renders all four tiers
  (Sandboxed/Standard/Privileged/Full Access) using the same color
  swatches as the badges on workspace cards. Eliminates the previous
  drift where Legend's hardcoded sky/violet/warm chips didn't match
  the gray/sky/violet/amber actually rendered on nodes.
- ConfigTab adds the missing T4 — Full Access option and renames T3
  to Privileged.
- tenant.ts comment updated to match the picker's actual hide list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:56:18 -07:00
Hongming Wang
df7edfcd3f fix(canvas): wire ReactFlow colorMode to resolvedTheme
PR #2555 (Tailwind v4 + warm-paper) migrated all canvas chrome (toolbar,
side panel, modal layer) to semantic tokens, but missed the React Flow
viewport's `colorMode="dark"` literal — and two paired hardcoded dark
literals on the Background dot color and MiniMap mask. Net result on
prod: the user picked light mode, the toolbar flipped warm-paper, but
the canvas backplate, edges, dots, controls, and minimap stayed black —
visibly half-themed.

Three coordinated fixes inside the canvas viewport:
- ReactFlow `colorMode={resolvedTheme}` so the library's own dark/light
  styles flip with the user's choice.
- Background dot color picks the line-soft tone in light mode (zinc-800
  was invisible-on-cream).
- MiniMap maskColor warm-tints the off-viewport dim so the unselected
  region doesn't render as a hard black bar over warm-paper.

Verification:
- `npx tsc --noEmit` clean
- `npx vitest run` 188/188 pass
- (will browser-verify post-redeploy on hongming.moleculesai.app)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:11:35 -07:00
Hongming Wang
db48d1d261 fix(canvas): restore text-white on saturated buttons + close zinc gaps
Independent code review of #2555 caught two contrast regressions left
by the bulk perl pass:

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:43:55 -07:00
Hongming Wang
bdd1d09dfb fix(canvas): tighten originalModel + pin store-flush failure-gating (review feedback)
PR #2545 self-review findings.

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

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

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

All 1214 canvas tests pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Comment-only change. No behavior change. 86 workspace tests + 91
canvas tests still pass.
2026-05-02 02:33:00 -07:00
Hongming Wang
3ce7c11a13 fix(canvas): revert v1 outbound file part shape
The previous PR (#2509) flipped canvas outbound file parts to the v1
flat shape `{url, filename, mediaType}` based on a hypothesis that
a2a-sdk's JSON-RPC parser silently dropped v0 `{kind:"file", file:{...}}`
shapes. Live test shows the opposite: a2a-sdk's JSON-RPC layer
validates against the v0 Pydantic discriminated union (TextPart |
FilePart | DataPart), so v1 flat shape is rejected with:

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

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

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

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

Three coordinated changes close the gap:

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

Test pins:
- workspace/tests/test_executor_helpers.py:
  + v1 protobuf shape extraction
  + empty-Part defense (v0→v1 silent-drop fall-through returns [])
- canvas message-parser test:
  + v1 protobuf flat parts
  + filename fallback to URL basename for v1
2026-05-02 00:58:05 -07:00
Hongming Wang
9abc9a0487 feat(canvas): always prompt provider+model on template deploy
Previously the picker modal opened only when preflight failed OR the
template offered ≥2 provider options. Single-provider templates with
saved keys (claude-code, langgraph) deployed silently using the
template's compiled-in default model — denying the user a final
chance to override before an EC2 boots and burns billing on the
wrong tier.

The picker UI already supports the "all-keys-saved single-provider"
case as a confirm-only prompt (provider radio is hidden, model input
is pre-filled with template.model), so flipping shouldShowPicker to
unconditional is a one-line change with the picker UX absorbing it.

Test plan
- Existing "single-provider skips picker when preflight.ok" regression
  guard inverted to assert picker always opens.
- Three happy-path tests refactored to drive through the picker via
  a new deployThroughPicker helper instead of expecting an immediate
  POST.
- POST-failure tests likewise refactored — the failure now surfaces
  through the picker click-through path, not the direct deploy()
  call.
- 15/15 tests pass; deploy-preflight.test.ts unchanged + 20/20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:19:14 -07:00
Hongming Wang
3ba924d174 review: drop destructive Override + single-fetch configuredKeys
Self-review of #2460 found two issues:

1. Critical: Override button in ProviderPickerModal called
   /settings/secrets when no workspaceId, overwriting the GLOBAL
   secret used by every workspace. The only consumers of this
   modal today (TemplatePalette, EmptyState via useTemplateDeploy)
   never pass workspaceId, so Override was always destructive.
   Removed entirely — the picker still solves the user-reported
   bug (always-ask + reuse saved keys); per-workspace key override
   can be a separate PR that plumbs secrets through POST /workspaces.

2. Optional: /settings/secrets was being fetched twice — once
   inside checkDeploySecrets (silently) and again in the hook to
   populate configuredKeys. Surfaced configuredKeys on
   PreflightResult so the hook re-uses the existing fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:40:58 -07:00
Hongming Wang
0608e15ab3 feat(canvas): always prompt for provider+model on multi-provider template deploy
Clicking a hermes template tile silently deployed when global env
covered the API key, producing "No LLM provider configured" 500
because the workspace booted with no explicit model slug — the
adapter fell back to its compiled-in default which 401s on the
user's actual provider key.

Fix: in useTemplateDeploy, open the picker whenever the template
declares ≥2 provider options, even when preflight.ok=true. The
modal renders pre-saved keys as Saved (with an Override link) and
adds a model input pre-filled from the template's default. Single-
provider templates (claude-code, langgraph) still skip the picker
since there's nothing to choose.

POST /workspaces now includes the picker's model slug so hermes-
style routing reads the prefix at install time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:34:17 -07:00
Hongming Wang
e1496936e9 feat(canvas): dynamic provider dropdown in CreateWorkspaceDialog
Mirrors the data-driven pattern PR #2454 set in ConfigTab: read
runtime_config.providers from /templates and filter the modal's
provider <select> to that subset. Same source of truth, three fewer
hardcoded copies of the provider list.

Behavior:
- Template declares providers → dropdown shows only those.
- Template ships no providers field → fall back to full HERMES_PROVIDERS
  catalog (back-compat for older templates / self-hosted setups).
- Declared list has no overlap with our static metadata → fall back to
  full catalog so the form can't lock the operator out.
- hermesProvider snaps back to the first available pick when its
  current value falls out of the filtered list.

Tests: 3 new pinning the filter, no-providers-field fallback, and
the unknown-providers fallback. All 27 CreateWorkspaceDialog tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:45:20 -07:00
Hongming Wang
517bd0efc5 feat(canvas+workspace-server): data-driven Provider dropdown (#199)
Option B PR-5. Canvas Config tab now exposes a Provider override input
that's adapter-driven from each runtime's template — no hardcoded
provider list in the canvas. PUT /workspaces/:id/provider on Save
when dirty; auto-restart suppression to avoid double-restart with
the model handler's own restart.

The dropdown's suggestion list comes from /templates →
runtime_config.providers (the field added in
molecule-ai-workspace-template-hermes PR #31). For templates that
haven't migrated to the explicit providers list yet, suggestions
derive from model[].id slug prefixes — still adapter-driven, just
inferred. This keeps existing templates working while platform team
migrates them one at a time.

workspace-server changes:
- Add Providers []string field to templateSummary JSON
- Parse runtime_config.providers in /templates handler
- 2 new tests pin the surfacing + omitempty behavior

canvas changes:
- Remove hardcoded PROVIDER_SUGGESTIONS constant
- Add provider/originalProvider state + PUT-on-save logic
- Add deriveProvidersFromModels() fallback helper
- Wire RuntimeOption.providers from /templates response
- 8 new tests pin the behavior end-to-end

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:19:17 -07:00
Hongming Wang
7706db5a93 fix(canvas): persist model on Save+Restart for runtime-bearing workspaces
The Model dropdown's onChange writes to config.runtime_config.model
whenever a runtime is set (hermes, claude-code, etc.), and only
falls back to top-level config.model when no runtime is selected.
But handleSave used to diff the new value against top-level
nextSource.model only — so for any runtime-bearing workspace, the
PUT /workspaces/:id/model never fired and MODEL_PROVIDER never
landed in workspace_secrets.

Symptom (2026-04-30, hongmingwang Hermes Agent
32993ee7-840e-4c02-8ca8-cb9d75d112a5):
  - User picks minimax/MiniMax-M2.7-highspeed from the dropdown
  - Hits Save & Restart
  - Save reports success; restart fires
  - The new EC2 boots with HERMES_DEFAULT_MODEL empty
  - install.sh defaults to nousresearch/hermes-4-70b
  - hermes-agent errors "No LLM provider configured" on every chat
    turn because no NOUS_API_KEY / OPENROUTER_API_KEY is set
  - Reload Config tab → model field reverts to whatever
    GET /workspaces/:id/model returns (i.e. empty / template default)

handleSave now reads the effective model from runtime_config.model
first and falls back to top-level model for legacy no-runtime
workspaces. Same change for the old-value diff so a no-op Save
still skips the PUT.

Tests pin both branches: PUTs /model when the dropdown changed
runtime_config.model on a hermes workspace; does NOT PUT when
the value is unchanged from what GET /model returned.
2026-04-30 18:31:43 -07:00
Hongming Wang
b54ceb799f fix: address 5-axis review findings on PR #2413
Critical:
- ExternalConnectModal.tsx: filledUniversalMcp substitution searched
  for WORKSPACE_AUTH_TOKEN but the snippet's placeholder is now
  MOLECULE_WORKSPACE_TOKEN (changed in the previous polish commit
  876c0bfc). Operators copy-pasting the MCP tab would have gotten a
  literal "<paste from create response>" instead of the token. Fix
  the substitution to match the new placeholder name.

Important:
- mcp_cli._platform_register: 401/403 from initial register now hard-
  exits with code 3 + an actionable stderr message pointing the
  operator at the canvas Tokens tab. Pre-fix: warning log + continue,
  which made a bad-token startup silently fail (heartbeat 401's
  forever, every tool call also 401's, no clear surfacing in the
  operator's MCP client). 500/503 still log + continue (transient
  platform blips shouldn't abort the MCP loop).
- a2a_mcp_server.cli_main docstring: removed stale claim that this is
  the wheel's console-script entry-point target. The actual target is
  mcp_cli.main since 2026-04-30. Wheel-smoke pins both names so the
  functionality was correct, but the doc was lying.

Test coverage: 3 new mcp_cli tests:
  - register 401 exits code=3 + stderr mentions canvas Tokens tab
  - register 403 (C18 hijack rejection) takes same path
  - register 500/503 does NOT exit — only auth errors hard-fail

Findings deferred to follow-up (acceptable per review rubric):
  - Code dedup across mcp_cli / heartbeat.py / molecule_agent SDK
  - Pooled httpx.Client for connection reuse
  - Heartbeat exponential backoff
  - Token-resolution ordering parity (env-first vs file-first)
    between mcp_cli.main and platform_auth.get_token

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:06:59 -07:00
Hongming Wang
876c0bfcd4 docs(canvas): update Universal MCP snippet — molecule-mcp now standalone
The canvas tab snippet for the Universal MCP path was written before
this PR added the built-in register + heartbeat thread. Earlier wording
described it as "outbound-only — pair with the Claude Code or Python SDK
tab for heartbeat + inbound messages" — that's stale. molecule-mcp now
handles register + heartbeat itself; the only thing it doesn't yet do is
inbound A2A delivery.

Updated:
- externalUniversalMcpTemplate header comment + body — describes
  standalone behavior, points operators at SDK/channel only when they
  need INBOUND (not heartbeat).
- Drops the now-redundant curl-register step from the snippet — the
  binary registers itself on startup.
- Canvas modal label likewise updated.

No runtime / behavior change; pure docs polish so a copy-pasting
operator's mental model matches what the binary actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:52:15 -07:00
Hongming Wang
716589742c feat(canvas): add Universal MCP tab to external-agent connect modal
The "Connect your external agent" dialog already covered Claude Code,
Python SDK, curl, and raw fields. This adds a Universal MCP tab that
documents the new \`molecule-mcp\` console script — the runtime-
agnostic baseline shipped by PR #2413's workspace-runtime changes.

Surface area:
- New \`externalUniversalMcpTemplate\` constant in workspace-server.
  Three-step snippet: pip install runtime → one-shot register via curl
  → wire molecule-mcp into agent's MCP config (Claude Code example,
  notes that hermes/codex/etc. take the same env-var contract).
- Workspace create response now includes \`universal_mcp_snippet\`
  alongside the existing curl/python/channel snippets.
- Canvas modal renders the tab when \`universal_mcp_snippet\` is
  present; backward-compatible with older platform builds (tab hides
  when empty).

Origin/WAF coverage (the user explicitly asked for this):
- The runtime wheel handles Origin automatically (this PR's earlier
  commit on platform_auth.auth_headers).
- The curl tab now sets \`Origin: {{PLATFORM_URL}}\` preemptively
  with an explanatory comment; \`/registry/register\` is currently
  WAF-allowed without it but adding now keeps the snippet working
  if WAF rules expand. The comment also explains why
  \`/workspaces/*\` paths return empty 404 without Origin — the
  exact failure mode I hit while smoke-testing this PR live.
- The MCP snippet's footer notes that the wheel auto-handles
  Origin so operators don't think about it.

End-to-end verification (against live tenant
hongmingwang.moleculesai.app, freshly registered workspace):
- get_workspace_info → full JSON
- list_peers → "Claude Code Agent (ID: 97ac32e9..., status: online)"
- recall_memory → "No memories found."
all returned by the molecule-mcp binary speaking MCP stdio to
this Claude Code session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:34:27 -07:00
Hongming Wang
fc3b5fd385 feat(canvas): add /api/buildinfo for version-display parity with tenant
Workspace-server has GET /buildinfo (PR #2398) — `curl https://<slug>.
moleculesai.app/buildinfo` returns the live git SHA. Canvas had no
parallel: debugging "is this the deployed code?" required reading
Vercel's UI or response headers (deployment ID, not git SHA).

Add canvas /api/buildinfo returning {git_sha, git_ref, vercel_env}
sourced from VERCEL_GIT_COMMIT_SHA / _REF / VERCEL_ENV — Vercel injects
these at build time from the deploying commit. Outside Vercel (local
`next dev`, harness) all three are unset and the endpoint returns
`git_sha: "dev"`, the same sentinel workspace-server uses pre-ldflags-
injection.

Now both surfaces speak the same vocabulary:

  curl https://<slug>.moleculesai.app/buildinfo
  curl https://canvas.moleculesai.app/api/buildinfo

3 tests cover dev-fallback, Vercel-injected SHA pass-through, and JSON
content type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:00:35 -07:00
Hongming Wang
15b98c4916 fix(e2e-canvas): kill teardown race that poisons concurrent runs
Setup wrote .playwright-staging-state.json at the END (step 7), only
after org create + provision-wait + TLS + workspace create + workspace-
online all succeeded. If setup crashed at steps 1-6, the org existed in
CP but the state file did not, so Playwright's globalTeardown bailed
out ("nothing to tear down") and the workflow safety-net pattern-swept
every e2e-canvas-<today>-* org to compensate. That sweep deleted
concurrent runs' live tenants — including their CF DNS records —
causing victims' next fetch to die with `getaddrinfo ENOTFOUND`.

Race observed 2026-04-30 on PR #2264 staging→main: three real-test
runs killed each other mid-test, blocking 68 commits of staging→main
promotion.

Fix: write the state file as setup's first action, right after slug
generation, before any CP call. Now:

  - Crash before slug gen        → no state file, no orphan to clean
  - Crash during steps 1-6       → state file has slug; teardown deletes
                                   it (DELETE 404s if org never created)
  - Setup completes              → state file has full state; teardown
                                   deletes the slug

The workflow safety-net no longer pattern-sweeps; it reads the state
file and deletes only the recorded slug. Concurrent canvas-E2E runs no
longer poison each other.

Verified by:
  - tsc --noEmit on staging-setup.ts + staging-teardown.ts
  - YAML lint on e2e-staging-canvas.yml
  - Code review: state file write moved to line 113 (post-makeSlug,
    pre-CP) with the original line-249 write retained as a "promote
    to full state" overwrite at the end
2026-04-29 19:23:56 -07:00
Hongming Wang
240d513ab8 canvas(ExternalConnectModal): add Claude Code tab + auto-fill auth_token
When the platform's create-external-workspace response includes
`claude_code_channel_snippet` (added in this same PR's first commit),
the modal surfaces it as the **first** tab — defaulting to it for new
external workspaces because polling-based + no-tunnel is the lowest-
friction path. Falls back to Python tab when the field is absent
(older platform builds).

Type addition is optional (`claude_code_channel_snippet?: string`)
so the canvas keeps building against pre-#2304 platform responses
during the soak window.

Auth-token stamping mirrors existing python/curl behavior — the
.env's `MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create
response>` placeholder gets filled in client-side so the copy-paste
block is truly ready to run.

Also adds the missing 'use client' directive — the file uses useState
+ useCallback but didn't have the Next.js client-component marker.
Pre-commit caught it; existing absence was a latent bug that would
surface as an SSR hook error if any path rendered this component
during server rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:39:48 -07:00
Hongming Wang
fc59f939ac chore(deps): batch dep bumps — 6 safe upgrades (4 actions majors + 2 npm dev deps)
Consolidates the remaining safe-to-merge dependabot PRs from the
2026-04-28 wave into one consumable PR. Replaces three earlier
single-bump PRs (#2245, #2230, #2231) which were closed in favor of
this single batch — same pattern as #2235.

GitHub Actions majors (SHA-pinned per org convention):
  github/codeql-action       v3 → v4.35.2  (#2228)
  actions/setup-node         v4 → v6.4.0   (#2218)
  actions/upload-artifact    v4 → v7.0.1   (#2216)
  actions/setup-python       v5 → v6.2.0   (#2214)

npm dev deps (canvas/, lockfile regenerated in node:22-bookworm
container so @emnapi/* and other Linux-only optional deps are
properly resolved — Mac-native `npm install` strips them, which
caused the earlier #2235 batch to drop these two):
  @types/node                ^22 → ^25.6   (#2231)
  jsdom                      ^25 → ^29.1   (#2230)

Why each is safe

  setup-node v4 → v6 / setup-python v5 → v6:
    Every consumer call pins node-version / python-version
    explicitly. v5 / v6 changed defaults but pinned consumers
    are unaffected. Confirmed via grep across .github/workflows/
    — all setup-node call sites pin '20' or '22', all
    setup-python call sites pin '3.11'.

  codeql-action v3 → v4.35.2:
    Used as init/autobuild/analyze sub-actions in codeql.yml.
    v4 bundles a newer CodeQL CLI; ubuntu-latest auto-updates
    so functional behavior is unchanged. The deprecated
    CODEQL_ACTION_CLEANUP_TRAP_CACHES env var (per v4.35.2
    release notes) is undocumented and we don't set it.

  upload-artifact v4 → v7.0.1:
    v6 introduced Node.js 24 runtime requiring Actions Runner
    >= 2.327.1. All upload-artifact users (codeql.yml,
    e2e-staging-canvas.yml) run on `ubuntu-latest` (GitHub-
    hosted), which auto-updates the runner agent. Self-hosted
    runners are NOT used for these jobs.

  @types/node 22 → 25 / jsdom 25 → 29:
    Both are dev-only — @types/node is type definitions,
    jsdom backs vitest's DOM environment. Tests pass:
    79 files / 1154 tests in node:22-bookworm container.

Verified locally (Linux container so the lockfile reflects what
CI's `npm ci` will install):
  - cd canvas && npm install --include=optional → 169 packages
  - npm test → 1154/1154 pass
  - npm ci → clean install succeeds
  - npm run build → Next.js prerendering succeeds

Closes when this lands (the 3 individual auto-merge PRs from earlier
were closed):
  #2228 #2218 #2216 #2214 #2231 #2230

NOT included (CI failing on dependabot's own run — major framework
bumps that need code-side migration tasks, not safe auto-bumps):
  #2233 next 15 → 16
  #2232 tailwindcss 3 → 4
  #2226 typescript 5 → 6
2026-04-28 17:44:55 -07:00
Hongming Wang
93e8e5329b
Merge pull request #2173 from Molecule-AI/deps/postcss-8.5.10-ghsa-qx2v-qp2m-jg93
deps(canvas): bump postcss 8.5.9 → 8.5.12 (GHSA-qx2v-qp2m-jg93, medium)
2026-04-27 20:20:13 +00:00
Hongming Wang
4028b81e04 refactor(canvas): route panel WS subscriptions through global socket
Both AgentCommsPanel and ChatTab's activity-feed opened raw
`new WebSocket(WS_URL)` instances per mount, with no onclose handler
and no reconnect logic. When the underlying connection dropped — idle
timeout, browser background-tab throttle, network jitter — the per-
panel sockets stayed dead until the panel re-mounted (refresh or
sub-tab unmount/remount). Live agent-comms bubbles and live activity
feed lines silently went missing in the gap, manifesting as "the
delegation didn't show up until I refreshed."

The global ReconnectingSocket in store/socket.ts already owns
reconnect, exponential backoff, health-check, and HTTP fallback poll.
Routing component subscribers through it gives every consumer those
guarantees for free, with one TCP connection per tab instead of N.

Three new pieces:

  - store/socket-events.ts: tiny pub/sub bus. emitSocketEvent fan-outs
    every decoded WSMessage to the listener Set; subscribeSocketEvents
    returns an unsubscribe. A throwing listener is logged and isolated
    so it can't break siblings.

  - store/socket.ts: ws.onmessage now calls emitSocketEvent(msg) right
    after applyEvent(msg), so the store's derived state and component
    subscribers stay in lockstep on every event arrival.

  - hooks/useSocketEvent.ts: React hook that registers exactly once
    per mount, capturing the latest handler in a ref so the closure
    sees current state/props without re-subscribing on every render.

Refactored sites:

  - AgentCommsPanel: replaced its WebSocket-in-useEffect block with
    useSocketEvent. Same parsing logic; the panel no longer opens its
    own connection.

  - ChatTab activity feed: split the previous useEffect in two — one
    seeds the activity log when `sending` flips, the other subscribes
    unconditionally and gates work on `sending` inside the handler.
    Hooks can't be conditional, so the gate has to live in the body
    rather than around the effect.

The ws-close graceful-close helper is no longer needed in either
site; the global socket owns its own teardown.

Tests: 6 new tests for the bus contract (single delivery, fan-out
order, unsubscribe, throwing-listener isolation, no-subscriber emit,
duplicate-subscribe Set semantics). All 27 existing socket tests
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:12:47 -07:00
Hongming Wang
f7ad5a82f7 fix(canvas): release sendInFlightRef in the activity-log WS path too
Third-pass review caught a fourth WS path I missed. The original fix +
the stale-callback follow-up patched 3 sites that release the in-flight
guards (pendingAgentMsgs effect, HTTP .then() success, HTTP .catch()
success), but the ACTIVITY_LOGGED handler at lines 410-419 also clears
`sending` + `sendingFromAPIRef` when the platform logs the workspace's
a2a_receive ok/error. It only cleared 2 of the 3 refs — same exact
bug class as the original. If THIS path wins the race (a2a_receive
activity logged before pendingAgentMsgs delivers the reply text),
sendInFlightRef stays stuck true and the next sendMessage() silently
no-ops at line 464.

Fix: route both branches (ok and error) through releaseSendGuards()
so all four sites are now uniform.

Updated the helper's docstring to explicitly list all four sites and
warn that any future "I saw the reply" path that only clears the
natural pair (sending + sendingFromAPIRef) will silently re-introduce
the freeze. The disabled-button logic can't see sendInFlightRef so
the visible state diverges from the synchronous re-entry guard
otherwise.

This is exactly the drift `releaseSendGuards()` was supposed to
prevent — the helper landed in the prior commit but the activity-log
site wasn't migrated to use it. Fixing now closes the gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:27:29 -07:00
Hongming Wang
cacf499354 fix(canvas): close stale-callback race + extract releaseSendGuards helper
Self-review on PR #2185 surfaced a latent race the original fix exposed:
the WS-clears-guards path now releases sendInFlightRef immediately, which
means a user can fire msg #2 between WS-arrival and HTTP-arrival for
msg #1. Without coordination, msg #1's late .then() sees
sendingFromAPIRef=true (set by msg #2's send), enters the main body,
and runs setSending(false) + appendMessageDeduped against msg #1's
response body — clobbering msg #2's in-flight UI state.

This race is realistic for claude-code SDK: the comment at line 294-298
already calls WS the "authoritative reply arrived" signal, and the user
typically reads-then-types before the trailing HTTP completes. Without
the original Send-button freeze "protecting" the race, it surfaces.

Two changes:

1. Token-keyed callbacks. sendTokenRef bumps on every sendMessage
   entry; .then()/.catch() capture the token in closure and bail
   without touching any flags if a newer send has superseded them.
   The newer send owns the in-flight guards.

2. releaseSendGuards() helper. The three-clear-guards trio
   (setSending, sendingFromAPIRef, sendInFlightRef) now lives in one
   useCallback so the WS handler, .then() success, and .catch()
   success can't drift apart. A future contributor dropping one of
   the three would silently re-introduce either the post-WS Send
   freeze or the stale-callback clobber.

Skipped a unit test for this regression — ChatTab has no __tests__
file and a mount test would need WS + zustand + api mocks. The fix
is 4 logical lines (token capture + 2 guard checks) and the manual
test covers it. Follow-up to add a focused mount test when ChatTab
gets its first __tests__ file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:47:12 -07:00
Hongming Wang
5faaf58466 fix(canvas): clear sendInFlightRef on WS-push reply path
Send button + Enter both silently no-op'd after the first agent reply
on runtimes that deliver via WebSocket (claude-code SDK does this per
the comment at ChatTab.tsx:294-298). The visible disabled-state checks
(sending, uploading, agentReachable) were all clean — the freeze came
from a third synchronous reentry guard the button can't see:

  if (sendInFlightRef.current) return;     // ChatTab.tsx:438

The ref was set true at the start of sendMessage() and only cleared in
.then() / .catch() of the HTTP fall-through and the upload-failure
branch. The WS-push handler in the pendingAgentMsgs effect cleared
`sending` and `sendingFromAPIRef` but left `sendInFlightRef` stuck
true. The HTTP .then() then early-returned at the dedup check (line
513) without touching the ref — only the .catch() early-return path
did. Net result: refresh fixed it because the ref reset on remount.

Two-line fix:
  - WS handler: also clear sendInFlightRef when the push delivers the
    reply (primary fix; no race window where the ref is stuck while
    the user can already type)
  - .then() early-return: mirror .catch()'s cleanup as defense in
    depth, so neither delivery order leaks the ref

While here: A2AEdge.test.tsx fixture was typed `as never` to dodge
EdgeProps' discriminated-union complaint, which broke spreading at
the call sites with TS2698 ("Spread types may only be created from
object types"). Replaced with `as unknown as ComponentProps<typeof
A2AEdge>` — preserves the original "skip restating every optional
field" intent and keeps a spreadable type.

All 10 A2AEdge tests pass; tsc --noEmit is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:11:58 -07:00
Hongming Wang
fc60b4bc5e fix(canvas): regenerate lockfile under Node 20 for npm ci compatibility
The first commit on this branch left the lockfile inconsistent for
Node 20's npm 10:

  npm error \`npm ci\` can only install packages when your package.json
  and package-lock.json are in sync. Please update your lock file...
  npm error Missing: @emnapi/runtime@1.10.0 from lock file
  npm error Missing: @emnapi/core@1.10.0 from lock file

Root cause: my local install ran on Node 24 / npm 11, which doesn't
write peer-optional transitive entries (@img/sharp-* declares
@emnapi/runtime as peerOptional). The Canvas tabs E2E job uses Node 20
/ npm 10, which DOES expect those entries and rejected the lockfile
with EUSAGE.

Regenerated the lockfile under Node 20.19.4 (matches the lowest CI
node version, lockfile is forward-compatible with 22 and 24). 6 new
@emnapi/* entries added; postcss stays at 8.5.12 (the original goal
of this branch).

Verification:
  - \`nvm use 20 && npm ci\` clean
  - 1148/1148 vitest pass under Node 20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:24:48 -07:00
Hongming Wang
6365e94213 deps(canvas): bump postcss 8.5.9 → 8.5.12 (GHSA-qx2v-qp2m-jg93)
Closes the medium-severity dependabot alert on canvas/package-lock.json.
Upstream advisory GHSA-qx2v-qp2m-jg93: "PostCSS has XSS via Unescaped
</style> in its CSS Stringify Output" — fixed in 8.5.10. We pull
8.5.12 since it's already published in the ^8.5.10 line.

package.json's caret range bumps from ^8.4.0 to ^8.5.12 — wider floor
prevents a future install from re-pinning below the safe version. The
8.x major-line constraint is preserved, so no breaking-change risk.

Verification: full canvas vitest suite passes (1148/1148 across
78 files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 05:59:02 -07:00
Hongming Wang
d46d558ca9
Merge pull request #2148 from Molecule-AI/test/canvas-lib-utils-runtime-names-1815
test(canvas): cover utils.cn + runtime-names.runtimeDisplayName (0% → 100%) (#1815)
2026-04-27 06:57:57 +00:00
Hongming Wang
a682dcb502
Merge pull request #2149 from Molecule-AI/test/canvas-actions-1815
test(canvas): cover canvas-actions restart-pending helpers (25% → 100%) (#1815)
2026-04-27 06:55:36 +00:00
Hongming Wang
ae029f8c3f
Merge pull request #2151 from Molecule-AI/test/canvas-class-names-1815
test(canvas): cover store/classNames helpers (17% → 100%) (#1815)
2026-04-27 06:54:37 +00:00
Hongming Wang
516b58dcd7
Merge pull request #2147 from Molecule-AI/feat/canvas-coverage-instrumentation-1815
feat(canvas): vitest coverage instrumentation (#1815, no CI gate yet)
2026-04-27 06:54:22 +00:00
Hongming Wang
679e30538a test(canvas): cover store/classNames helpers (17% → 100%) (#1815)
[Molecule-Platform-Evolvement-Manager]

Continues the #1815 coverage rollup. classNames.ts was at 17%
in the baseline; this PR brings it to full coverage.

16 cases across 3 helpers:

**appendClass (6):**
- undefined / empty existing → just `cls`
- single-class → "a b" join
- DEDUP: existing already contains `cls` → existing unchanged.
  This is the load-bearing reason classNames.ts exists. Pre-helper
  the call sites inlined `${existing} ${cls}` with no dedup, so a
  tick that fired the same class twice produced "a a" and React
  Flow's className-equality diff saw it as a change every render.
- whitespace normalization (multi-space, leading/trailing)

**removeClass (7):**
- undefined / empty existing → ""
- removes named class
- exact match only ("spawn" must NOT match "spawn-fast")
- removing the only class → ""
- no-op when class absent
- whitespace normalization

**scheduleNodeClassRemoval (3):**
- after delayMs: calls set() with className-removed on target node;
  OTHER nodes untouched (the per-id pruning is the contract — pin
  it so a future refactor that maps over all nodes doesn't silently
  strip classes from siblings)
- does NOT fire before the delay elapses (vi.useFakeTimers + advance)
- SSR safety: when window is undefined, function is a no-op
  (neither get nor set fires)

## Note on test environment

Added `// @vitest-environment jsdom` directive — the file's
default `node` environment leaves `window` undefined, which would
make the SSR-guard happy-path test pass for the wrong reason
(every test would short-circuit). With jsdom, the SSR test
explicitly stubs `window` to undefined to exercise the guard.

## Test plan

- [x] All 16 cases pass locally (~1.1s with jsdom env spin-up)
- [x] No SUT changes
- [ ] CI green

## #1815 progress

- [x] Step 1+2: instrumentation (#2147)
- [x] utils.ts + runtime-names.ts (#2148)
- [x] canvas-actions.ts (#2149)
- [x] store/classNames.ts (this PR)
- [ ] store/canvas.ts (73% — biggest absolute gap; bigger surface,
      separate cycle)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:50:00 -07:00