From baa7e1531fce336752f70dcb1af68fec523d8b8a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 16:41:09 -0700 Subject: [PATCH] feat(canvas): provider-picker MissingKeysModal for multi-provider runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtimes like Hermes and LangGraph accept any one of several LLM provider keys (OpenRouter OR OpenAI OR Anthropic OR Nous-native). Before this change, the missing-keys modal treated all supported providers as simultaneously required — a fresh user on Hermes was asked for three parallel API keys when any one suffices. Introduces RUNTIME_PROVIDERS in deploy-preflight.ts as the canonical per-runtime provider list (label, envVar, note). checkDeploySecrets now returns all alternatives as missingKeys when nothing is configured, so the modal can offer a picker. MissingKeysModal dispatches between two render paths: * ProviderPickerModal — radio list of supported providers, a single env input for the chosen one. Saving that one key satisfies the preflight. Activated whenever the runtime has ≥2 provider choices. * AllKeysModal — legacy parallel-inputs UX, all keys must be saved before deploy. Kept for single-provider runtimes (claude-code, gemini-cli) and callers that pass unrelated-key lists. Dual-mode preserves the pre-existing contract for every caller while fixing the multi-provider UX. All 930 canvas vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/MissingKeysModal.tsx | 346 ++++++++++++++++-- .../__tests__/MissingKeysModal.a11y.test.tsx | 4 + .../MissingKeysModal.component.test.tsx | 9 +- .../__tests__/MissingKeysModal.test.tsx | 8 +- .../lib/__tests__/deploy-preflight.test.ts | 20 +- canvas/src/lib/deploy-preflight.ts | 116 +++++- 6 files changed, 448 insertions(+), 55 deletions(-) diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 91346776..701a451e 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,14 +1,18 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { api } from "@/lib/api"; -import { getKeyLabel } from "@/lib/deploy-preflight"; +import { + getKeyLabel, + getRuntimeProviders, + type ProviderChoice, +} from "@/lib/deploy-preflight"; interface Props { open: boolean; missingKeys: string[]; runtime: string; - /** Called when user adds all keys and wants to proceed with deploy. */ + /** Called when user adds all required keys and wants to proceed with deploy. */ onKeysAdded: () => void; /** Called when user cancels the deploy. */ onCancel: () => void; @@ -27,6 +31,24 @@ interface KeyEntry { error: string | null; } +/** + * MissingKeysModal + * ---------------- + * Two rendering modes, picked automatically from the runtime: + * + * 1. PROVIDER-PICKER mode — when `getRuntimeProviders(runtime)` returns + * ≥2 alternatives. The modal shows a radio list of supported + * providers first ("Hermes supports OpenRouter / OpenAI / Nous + * native — pick one") and only the chosen provider's env input + * below. Saving that one key satisfies the deploy. + * + * 2. LEGACY all-keys mode — when the runtime has <2 provider + * alternatives, or the caller supplied multiple unrelated keys. + * Renders one input per `missingKeys` entry; all must be saved + * before deploy. Preserves the pre-provider-picker contract so + * callers that pass unrelated-key lists (e.g. a workspace that + * needs an LLM key AND a separate tool key) keep working. + */ export function MissingKeysModal({ open, missingKeys, @@ -35,12 +57,291 @@ export function MissingKeysModal({ onCancel, onOpenSettings, workspaceId, +}: Props) { + const providers: ProviderChoice[] = useMemo( + () => getRuntimeProviders(runtime), + [runtime], + ); + + // Picker mode activates only when we have a real provider list with + // genuine alternatives. If the runtime is unknown (providers=[]) or + // has a single forced provider, fall back to the legacy all-keys UX. + const pickerMode = providers.length > 1; + + if (pickerMode) { + return ( + + ); + } + + return ( + + ); +} + +// ----------------------------------------------------------------------------- +// Provider-picker mode — one-of-N providers, save one, deploy. +// ----------------------------------------------------------------------------- + +function ProviderPickerModal({ + open, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: { + open: boolean; + providers: ProviderChoice[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { + const [selectedId, setSelectedId] = useState(providers[0].id); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const firstInputRef = useRef(null); + + useEffect(() => { + if (!open) return; + setSelectedId(providers[0].id); + setValue(""); + setSaving(false); + setSaved(false); + setError(null); + }, [open, providers]); + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); + return () => cancelAnimationFrame(raf); + }, [open, selectedId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onCancel]); + + const selected = providers.find((p) => p.id === selectedId) ?? providers[0]; + + const handleSave = useCallback(async () => { + if (!value.trim()) return; + setSaving(true); + setError(null); + try { + if (workspaceId) { + await api.put(`/workspaces/${workspaceId}/secrets`, { + key: selected.envVar, + value: value.trim(), + }); + } else { + await api.put("/settings/secrets", { + key: selected.envVar, + value: value.trim(), + }); + } + setSaved(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + }, [selected, value, workspaceId]); + + if (!open) return null; + + const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
+ + ); +} + +// ----------------------------------------------------------------------------- +// Legacy all-keys mode — every missingKey rendered as its own input, +// all must save before deploy. Kept for single-provider runtimes + +// callers that pass unrelated-key lists (old contract). +// ----------------------------------------------------------------------------- + +function AllKeysModal({ + open, + missingKeys, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, }: Props) { const [entries, setEntries] = useState([]); const [globalError, setGlobalError] = useState(null); const firstInputRef = useRef(null); - // Initialize entries when modal opens or missingKeys change useEffect(() => { if (!open) return; setEntries( @@ -56,14 +357,12 @@ export function MissingKeysModal({ setGlobalError(null); }, [open, missingKeys]); - // Focus first input when modal opens useEffect(() => { if (!open) return; - const raf = requestAnimationFrame(() => { - firstInputRef.current?.focus(); - }); + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); return () => cancelAnimationFrame(raf); }, [open]); + useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -90,7 +389,6 @@ export function MissingKeysModal({ updateEntry(index, { saving: true, error: null }); try { - // Save to global scope by default (available to all workspaces) if (workspaceId) { await api.put(`/workspaces/${workspaceId}/secrets`, { key: entry.key, @@ -135,31 +433,19 @@ export function MissingKeysModal({ return (
- {/* Backdrop */} -