Merge pull request #2665 from Molecule-AI/staging

staging → main: auto-promote d684e28
This commit is contained in:
Hongming Wang 2026-05-03 18:23:38 -07:00 committed by GitHub
commit ad24703d74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 344 additions and 10 deletions

View File

@ -127,13 +127,16 @@ export function DeleteCascadeConfirmDialog({
</p>
</div>
{/* Checkbox guard */}
{/* Checkbox guard. Ring-offset color was zinc-900 the dialog
actually sits on bg-surface-sunken, so the offset showed
the wrong color through the ring gap. Switched to the
real bg + a danger-tinted ring. */}
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
/>
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
I understand this will permanently delete all listed workspaces and their data
@ -145,7 +148,11 @@ export function DeleteCascadeConfirmDialog({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
// Was hover:bg-surface-card (same as base — silent no-op).
// Lift to surface-elevated to match the Cancel pattern in
// ConfirmDialog. Added focus-visible ring so keyboard users
// see where focus lands.
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Cancel
</button>
@ -153,9 +160,12 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>

View File

@ -18,6 +18,157 @@
import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
// Per-tab help metadata: docs link, where-to-install link, common errors.
// All URLs verified against repo content (docs/guides/* file paths map to
// docs.molecule.ai/docs/guides/*; canonical hostname confirmed by existing
// blog post canonical metadata) or against the snippet text the operator
// just copied. Never linking to a URL that wasn't already in product —
// dead links here defeat the purpose of "more comprehensive instructions."
const TAB_HELP: Record<
Tab,
{
docsUrl?: string;
docsLabel?: string;
downloadUrl?: string;
downloadLabel?: string;
commonIssues?: { symptom: string; check: string }[];
}
> = {
mcp: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "Tools not appearing in your agent",
check:
"Run `claude mcp list` (or your runtime's equivalent) — the molecule entry should be listed. If missing, re-run the `claude mcp add` line.",
},
{
symptom: "ConnectionRefused / DNS error on first call",
check:
"PLATFORM_URL must include the scheme (https://) and have no trailing slash. Verify with `curl $PLATFORM_URL/healthz`.",
},
],
},
python: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "401 from /heartbeat",
check:
"AUTH_TOKEN expired or wrong workspace_id. Tokens are shown only once at create time — re-create the workspace to get a fresh token.",
},
{
symptom: "AGENT_URL not reachable from platform",
check:
"Public HTTPS URL required for inbound A2A. Use ngrok or Cloudflare Tunnel if your agent is behind NAT.",
},
],
},
claude: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://claude.com/claude-code",
downloadLabel: "Claude Code (claude.com)",
commonIssues: [
{
symptom: "plugin not installed",
check:
"Run `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` then `/plugin install molecule@molecule-mcp-claude-channel` inside Claude Code, then `/reload-plugins`.",
},
{
symptom: "not on the approved channels allowlist",
check:
"Custom channels need `--dangerously-load-development-channels` on the launch command. Team/Enterprise orgs need admin to set `channelsEnabled` + `allowedChannelPlugins` in claude.ai admin settings.",
},
{
symptom: "Inbound messages not arriving",
check:
"Check stderr for `molecule channel: connected — watching N workspace(s)`. Verify ~/.claude/channels/molecule/.env has the right PLATFORM_URL + token.",
},
],
},
hermes: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://github.com/NousResearch/hermes-agent",
downloadLabel: "hermes-agent (NousResearch)",
commonIssues: [
{
symptom: "Gateway start failure",
check:
"Tail ~/.hermes/gateway.log. YAML duplicate-key in config.yaml is the most common cause — `gateway:` block must appear exactly once.",
},
{
symptom: "Plugin not discovered after install",
check:
"Run `pip show hermes-channel-molecule` to confirm install. Some hermes builds need `hermes plugin reload` before the new platform_plugins entry takes effect.",
},
],
},
codex: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://github.com/openai/codex",
downloadLabel: "openai/codex",
commonIssues: [
{
symptom: "[mcp_servers.molecule] not loaded",
check:
"Codex must be ≥ 0.57. Check with `codex --version`; upgrade via `npm install -g @openai/codex@latest`.",
},
{
symptom: "TOML parse error after re-running setup",
check:
"TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.",
},
],
},
openclaw: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
commonIssues: [
{
symptom: "Gateway not starting",
check:
"Tail ~/.openclaw/gateway.log. The loopback bind requires :18789 to be free — check with `lsof -iTCP:18789`.",
},
{
symptom: "openclaw mcp set rejected",
check:
"The heredoc generates JSON; verify it parsed by running `jq < ~/.openclaw/mcp/molecule.json`. Re-run `openclaw mcp set` if the file is malformed.",
},
],
},
curl: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
commonIssues: [
{
symptom: "401 / 403 on register",
check:
"WORKSPACE_AUTH_TOKEN must be the value shown at workspace create. Tokens are shown only once.",
},
],
},
fields: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
},
};
export interface ExternalConnectionInfo {
workspace_id: string;
platform_url: string;
@ -63,8 +214,6 @@ interface Props {
onClose: () => void;
}
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
export function ExternalConnectModal({ info, onClose }: Props) {
// Default to Universal MCP when the platform offers it — runtime-
// agnostic outbound tool path that works for any MCP-aware runtime
@ -303,6 +452,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
<HelpBlock help={TAB_HELP[tab]} />
</div>
<div className="mt-5 flex justify-end gap-2">
@ -351,6 +501,70 @@ function SnippetBlock({
);
}
// HelpBlock — collapsible "Need help?" section under each tab's snippet.
// Renders only the keys present in the per-tab help metadata (no empty
// sections). Closed by default so the snippet stays the visual focus;
// operators with a working setup never see this. Uses native <details>
// for keyboard accessibility (Tab + Enter) without extra ARIA wiring.
function HelpBlock({
help,
}: {
help: (typeof TAB_HELP)[Tab] | undefined;
}) {
if (!help) return null;
const { docsUrl, docsLabel, downloadUrl, downloadLabel, commonIssues } = help;
if (!docsUrl && !downloadUrl && !commonIssues?.length) return null;
return (
<details className="mt-3 border border-line rounded-lg bg-surface text-xs">
<summary className="cursor-pointer select-none px-3 py-2 text-ink-mid hover:text-ink">
Need help? install link, docs, common errors
</summary>
<div className="px-3 pb-3 pt-1 space-y-2">
{downloadUrl && (
<div>
<span className="text-ink-soft">Where to install: </span>
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{downloadLabel || downloadUrl}
</a>
</div>
)}
{docsUrl && (
<div>
<span className="text-ink-soft">Documentation: </span>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{docsLabel || docsUrl}
</a>
</div>
)}
{commonIssues && commonIssues.length > 0 && (
<div>
<div className="text-ink-soft mb-1">Common errors:</div>
<ul className="space-y-1.5 pl-3">
{commonIssues.map((issue, i) => (
<li key={i}>
<code className="text-warm font-mono">{issue.symptom}</code>
<span className="text-ink-mid"> {issue.check}</span>
</li>
))}
</ul>
</div>
)}
</div>
</details>
);
}
function Field({
label,
value,

View File

@ -472,6 +472,7 @@ function GroupedCommsView({
<NormalMessage key={msg.id} msg={msg} />
),
)}
<WaitingBubbles visible={visible} />
<div ref={bottomRef} />
</div>
</div>
@ -560,6 +561,83 @@ function PeerTabButton({
);
}
/** WaitingBubbles renders one "typing" bubble per peer that has an
* in-flight outbound delegation i.e., the most recent outbound
* message to that peer is still pending or queued and no later inbound
* reply has arrived. Mirrors the bouncing-dots indicator in ChatTab so
* the operator sees the same visual cue regardless of whether they're
* watching their own chat or a peer thread.
*
* Why "per peer" not "one global": when multiple delegations are in
* flight to different peers (common during fan-out), one shared
* spinner under-reports the user can't tell whether ALL peers are
* still working or only the visible ones. Per-peer matches Slack-style
* typing indicators and keeps the signal honest.
*
* Why we look at the LAST per-peer message: once a peer replies (an
* "in" bubble lands), the corresponding "out" bubble is no longer the
* tail even if status hasn't been mutated to "completed", the inbound
* reply means the wait is over. Looking at the tail collapses both
* cases into one rule.
*/
function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
// Group by peer, keep only the chronologically-last message per peer,
// emit a bubble when that tail is an outbound pending/queued.
const tailByPeer = new Map<string, CommMessage>();
for (const m of visible) {
const prev = tailByPeer.get(m.peerId);
if (!prev || m.timestamp > prev.timestamp) tailByPeer.set(m.peerId, m);
}
const waitingPeers = Array.from(tailByPeer.values()).filter(
(m) => m.flow === "out" && (m.status === "pending" || m.status === "queued"),
);
if (waitingPeers.length === 0) return null;
return (
<>
{waitingPeers.map((m) => (
<div
key={`waiting-${m.peerId}`}
className="flex justify-end"
// Outbound thread → right-justified to match the "out" bubble
// alignment, so the dots feel like they belong to the message
// they're replying to.
>
<div
className="max-w-[85%] rounded-lg px-3 py-2 text-xs bg-cyan-900/30 border border-cyan-700/20"
// role+aria-label so screen readers announce the wait;
// matches the announcing pattern used by Toaster.
role="status"
aria-label={`Waiting for reply from ${m.peerName}`}
>
<div className="text-[9px] text-ink-soft mb-1"> To {m.peerName}</div>
<span className="flex items-center gap-2 text-ink-mid">
<span className="flex gap-0.5" aria-hidden="true">
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</span>
<span className="text-[10px]">
{m.status === "queued"
? `${m.peerName} is busy — reply will arrive when they're free`
: `Waiting for ${m.peerName}`}
</span>
</span>
</div>
</div>
))}
</>
);
}
function NormalMessage({ msg }: { msg: CommMessage }) {
return (
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>

View File

@ -83,7 +83,20 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
const externalChannelTemplate = `# Claude Code channel bridges this workspace's A2A traffic into your
# Claude Code session. No tunnel/public URL needed (polling-based).
#
# 1. Save this token + workspace_id, then create ~/.claude/channels/molecule/.env:
# Prereq: Bun installed (channel plugins are Bun scripts).
# bun --version # must print a version number
#
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
# The plugin is NOT on Anthropic's default allowlist, so a one-time
# marketplace-add is needed before install:
#
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
# /plugin install molecule@molecule-mcp-claude-channel
#
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
#
# 2. Create the per-watched-workspace config file:
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
@ -92,13 +105,32 @@ MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
EOF
chmod 600 ~/.claude/channels/molecule/.env
# 2. Launch Claude Code with the channel enabled:
claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
# allowlisted) channels need the --dangerously-load-development-channels
# flag to opt in without it, you'll see "not on the approved channels
# allowlist" on startup.
claude --dangerously-load-development-channels \
--channels plugin:molecule@molecule-mcp-claude-channel
# You should see on stderr:
# molecule channel: connected watching 1 workspace(s) at {{PLATFORM_URL}}
#
# Inbound A2A messages now surface as conversation turns. Claude's
# replies route back via the reply_to_workspace MCP tool no extra
# wiring on your side.
#
# Common errors:
# "plugin not installed" Step 1 didn't run; run /plugin install
# inside Claude Code, then /reload-plugins.
# "not on approved channels allowlist" Add --dangerously-load-development-channels
# to the launch command (Step 3).
# "config-missing" ~/.claude/channels/molecule/.env not
# readable; re-run Step 2 and check chmod.
#
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
# blocked by managed settings. Your admin must set channelsEnabled=true and
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.