From 240d513ab84510c53fec5021feea42aa09a86d87 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 11:39:48 -0700 Subject: [PATCH] canvas(ExternalConnectModal): add Claude Code tab + auto-fill auth_token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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) --- .../src/components/ExternalConnectModal.tsx | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index 10fc4f85..a10fb54c 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -1,3 +1,5 @@ +'use client'; + // ExternalConnectModal — shown once after creating a runtime="external" // workspace. Surfaces the workspace_auth_token + ready-to-paste snippets // so the operator can hand them to whoever runs their off-host agent @@ -24,6 +26,12 @@ export interface ExternalConnectionInfo { heartbeat_endpoint: string; curl_register_template: string; python_snippet: string; + // Claude Code channel plugin snippet — for operators whose external + // agent IS a Claude Code session. Polling-based; no tunnel required. + // Optional in the type for backward compat with platforms that + // haven't shipped molecule-core PR #2304 yet (older response payload + // omits the field; tab is hidden if empty). + claude_code_channel_snippet?: string; } interface Props { @@ -31,10 +39,14 @@ interface Props { onClose: () => void; } -type Tab = "python" | "curl" | "fields"; +type Tab = "python" | "curl" | "claude" | "fields"; export function ExternalConnectModal({ info, onClose }: Props) { - const [tab, setTab] = useState("python"); + // Default to Claude Code when the platform offers it — that's the + // newest + simplest path (no tunnel needed). Falls back to Python + // for older platform builds that don't ship the snippet. + const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python"; + const [tab, setTab] = useState(initialTab); const [copiedKey, setCopiedKey] = useState(null); const copy = useCallback(async (value: string, key: string) => { @@ -70,6 +82,13 @@ export function ExternalConnectModal({ info, onClose }: Props) { 'WORKSPACE_AUTH_TOKEN=""', `WORKSPACE_AUTH_TOKEN="${info.auth_token}"`, ); + // The channel snippet asks the operator to paste the auth_token into + // the .env file's MOLECULE_WORKSPACE_TOKENS field. Stamp it server-side + // here so the copy-paste-block is truly ready-to-run. + const filledChannel = info.claude_code_channel_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKENS=', + `MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`, + ); return ( !o && onClose()}> @@ -91,7 +110,11 @@ export function ExternalConnectModal({ info, onClose }: Props) { aria-label="Connection snippet format" className="mt-4 flex gap-1 border-b border-zinc-800" > - {(["python", "curl", "fields"] as Tab[]).map((t) => ( + {( + filledChannel + ? (["claude", "python", "curl", "fields"] as Tab[]) + : (["python", "curl", "fields"] as Tab[]) + ).map((t) => ( ))} {/* Snippet area */}
+ {tab === "claude" && filledChannel && ( + copy(filledChannel, "claude")} + /> + )} {tab === "python" && ( copy(filledPython, "python")}