Merge pull request #2665 from Molecule-AI/staging
staging → main: auto-promote d684e28
This commit is contained in:
commit
ad24703d74
@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"}`}>
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user