import { useEffect, useRef, useState, type ReactNode } from "react"; import { getProviders, removeProvider, setProvider, verifyProvider, type ProviderField as ProviderFieldT, type ProviderInfo, } from "../api"; import { openExternal } from "../tauri"; import { PROVIDER_LOGOS, providerRank } from "./logos"; // The provider gallery ⇄ key form, shared by Onboarding step 1 (§39) and // Settings ▸ Models (UX-021) so the two can never drift apart visually. The hook // owns the interaction state machine; ProviderCards/ProviderForm own the shared // markup. Each surface keeps its own frame (fixed-height modal vs scrolling page) // and passes a testid prefix so both stay independently addressable in e2e. // Where a non-developer gets an API key — deep link + one line of instructions. export const KEY_HELP: Record = { anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" }, openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" }, gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" }, openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" }, bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" }, fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" }, together: { url: "https://api.together.xyz/settings/api-keys", label: "together.xyz" }, zai: { url: "https://z.ai/manage-apikey/apikey-list", label: "z.ai" }, kimi: { url: "https://platform.moonshot.ai/console/api-keys", label: "platform.moonshot.ai" }, deepseek: { url: "https://platform.deepseek.com/api_keys", label: "platform.deepseek.com" }, mistral: { url: "https://console.mistral.ai/api-keys", label: "console.mistral.ai" }, qwen: { url: "https://modelstudio.console.alibabacloud.com", label: "alibabacloud.com" }, minimax: { url: "https://platform.minimax.io", label: "platform.minimax.io" }, xai: { url: "https://console.x.ai", label: "console.x.ai" }, }; export type Verify = { state: "idle" | "testing" | "ok" | "error"; msg?: string }; /** Brand chip: always a light plate so multicolor marks read on any theme. */ export function ProviderMark({ name, title, size = 32 }: { name: string; title: string; size?: number }) { const url = PROVIDER_LOGOS[name]; return ( {url ? ( ) : ( {title[0]} )} ); } /** "2h ago"-style label for a provider's last completion (null when never used). */ export function relTime(epoch?: number | null): string | null { if (!epoch) return null; const secs = Math.max(0, Math.floor(Date.now() / 1000 - epoch)); if (secs < 90) return "just now"; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 48) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; } export interface ProviderSetupState { providers: ProviderInfo[]; ordered: ProviderInfo[]; refreshProviders: () => Promise; sel: string | null; info: ProviderInfo | undefined; fields: Record; setFieldValue: (key: string, value: string) => void; dirty: boolean; verify: Verify; showEndpoint: boolean; setShowEndpoint: (v: boolean) => void; keylessOk: Set; credentialed: boolean; savedState: boolean; secretFilled: boolean; openProvider: (name: string) => void; backToGallery: () => void; runTestAndSave: () => Promise; removeKey: () => Promise; cancelBackTimer: () => void; statusFor: (p: ProviderInfo, opts?: { lastUsed?: boolean }) => ReactNode; // Blur-save for non-secret fields on an already-configured provider (the Test button is // the KEY's save path; extras like anthropic's thinking_budget must not need a re-test — // owner-hit 2026-07-23: the budget silently never saved). saveField: (key: string) => Promise; fieldSaved: string | null; // field key flashing "✓ Saved" } export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetupState { const [providers, setProviders] = useState([]); // null = the gallery; a provider name = that provider's key form. const [sel, setSel] = useState(null); const [fields, setFields] = useState>({}); const [dirty, setDirty] = useState(false); const [showEndpoint, setShowEndpoint] = useState(false); const [verify, setVerify] = useState({ state: "idle" }); // Keyless providers (Ollama) report configured without proving anything runs — // a passing Detect this session is what marks them live. const [keylessOk, setKeylessOk] = useState>(new Set()); // Unsaved per-provider input survives switching cards (owner complaint 2026-07-16). const [drafts, setDrafts] = useState>>({}); const backTimer = useRef(null); // Which non-secret field just blur-saved (flashes "✓ Saved" in the input). const [fieldSaved, setFieldSaved] = useState(null); const fieldSavedTimer = useRef(null); const refreshProviders = () => getProviders() .then(setProviders) .catch(() => {}); useEffect(() => { refreshProviders(); return () => { if (backTimer.current) window.clearTimeout(backTimer.current); }; }, []); const info = providers.find((p) => p.name === sel); const credentialed = !!info?.configured && !!info?.needs_key; const openProvider = (name: string) => { const p = providers.find((x) => x.name === name); if (sel) setDrafts((d) => ({ ...d, [sel]: fields })); const draft = drafts[name]; const next: Record = {}; for (const f of p?.fields || []) next[f.key] = draft?.[f.key] || p?.values?.[f.key] || f.default || ""; setSel(name); setFields(next); setDirty(!!draft && Object.values(draft).some(Boolean)); setVerify({ state: "idle" }); setShowEndpoint(false); }; const backToGallery = () => { // Stash only UNSAVED input. The unconditional stash used to capture the just-saved // key on the post-Test auto-return, so revisiting a connected provider restored the // plaintext key into the field instead of the masked placeholder + saved pill // (state-restore bug, owner catch 2026-07-19). A clean form clears any stale draft. if (sel) setDrafts((d) => ({ ...d, [sel]: dirty ? fields : {} })); setSel(null); setVerify({ state: "idle" }); }; // Test = verify AND save AND return (§39: a passing Test auto-saves and takes // you back to the gallery, where the card now wears its ✓ — no extra clicks). const runTestAndSave = async (): Promise => { if (!sel) return false; setVerify({ state: "testing" }); const res = await verifyProvider(sel, fields).catch(() => ({ ok: false, error: "unreachable" })); if (!res.ok) { setVerify({ state: "error", msg: res.error || "couldn't verify" }); return false; } if (dirty || !info?.configured) await setProvider(sel, fields).catch(() => {}); if (!info?.needs_key) setKeylessOk((s) => new Set(s).add(sel)); setVerify({ state: "ok" }); setDirty(false); setDrafts((d) => ({ ...d, [sel]: {} })); await refreshProviders(); opts?.onSaved?.(); // Let the in-field "✓ Tested & saved" register, then slide home. NOT backToGallery: // the timeout would fire its stale closure (dirty/fields from before the save) and // re-stash the just-saved key as a draft — the state-restore bug (owner catch // 2026-07-19). This return path clears the draft unconditionally. backTimer.current = window.setTimeout(() => { setDrafts((d) => ({ ...d, [sel]: {} })); setSel(null); setVerify({ state: "idle" }); }, 900); return true; }; // Blur-save for non-secret fields when the provider is already configured: extras like // anthropic's thinking_budget must persist without a key re-test (owner-hit 2026-07-23 — // typed, left Settings, silently never saved). Secrets keep the explicit Test-to-save // contract; unconfigured providers save everything on their first Test. const saveField = async (key: string) => { if (!sel || !info?.configured) return; const spec = info.fields.find((f) => f.key === key); if (!spec || spec.secret) return; const current = (fields[key] || "").trim(); const stored = (info.values?.[key] || "").trim(); if (current === stored) return; const res = await setProvider(sel, { [key]: current }).catch(() => ({ ok: false })); if (!res.ok) return; await refreshProviders(); opts?.onSaved?.(); setFieldSaved(key); if (fieldSavedTimer.current) window.clearTimeout(fieldSavedTimer.current); fieldSavedTimer.current = window.setTimeout(() => setFieldSaved(null), 1400); }; // Settings-only: forget the stored key; the card reverts to "Not set up". const removeKey = async () => { if (!sel) return; await removeProvider(sel).catch(() => {}); setDrafts((d) => ({ ...d, [sel]: {} })); setKeylessOk((s) => { const next = new Set(s); next.delete(sel); return next; }); await refreshProviders(); opts?.onSaved?.(); setSel(null); setVerify({ state: "idle" }); }; const statusFor = (p: ProviderInfo, o?: { lastUsed?: boolean }) => { if (p.configured && p.needs_key) { const used = o?.lastUsed ? relTime(p.last_used_at) : null; return ( ✓ Connected{used ? · used {used} : ""} ); } if (!p.needs_key) return ( {keylessOk.has(p.name) ? ✓ Running : "No key needed"} ); return Not set up; }; return { providers, ordered: [...providers].sort((a, b) => providerRank(a.name) - providerRank(b.name)), refreshProviders, sel, info, fields, setFieldValue: (key, value) => { setFields((cur) => ({ ...cur, [key]: value })); setDirty(true); setVerify({ state: "idle" }); }, dirty, verify, showEndpoint, setShowEndpoint, keylessOk, credentialed, // The in-field saved state (§39): green border + pill INSIDE the key box — shown // for stored credentials and fresh test-passes alike; typing clears it. savedState: (credentialed && !dirty) || verify.state === "ok", // Only REQUIRED secrets gate the Test button — cloud providers (Bedrock, Vertex) // have optional key fields whose credentials may live in ~/.aws or ADC instead. secretFilled: (info?.fields || []).every( (f) => !f.secret || !f.required || (fields[f.key] || "").trim(), ), openProvider, backToGallery, runTestAndSave, removeKey, saveField, fieldSaved, cancelBackTimer: () => { if (backTimer.current) window.clearTimeout(backTimer.current); }, statusFor, }; } /** The gallery: one card per provider, each wearing its own state. */ export function ProviderCards({ ps, tp, gridClass = "grid grid-cols-2 gap-2.5", lastUsed = false, }: { ps: ProviderSetupState; tp: string; // testid prefix ("ob" onboarding, "set" settings) gridClass?: string; lastUsed?: boolean; }) { const card = "flex items-center gap-2.5 rounded-xl border border-line bg-panel px-3 py-2.5 text-left hover:border-lineStrong transition-colors"; return (
{ps.ordered.map((p) => ( ))}
); } /** One provider's key form: crumb, brand head, fields (endpoint behind a quiet * disclosure), in-field saved pill, Test/Detect, key help, fixed error line. * `footer` renders after the error line (Settings adds "Remove key…" there). */ export function ProviderForm({ ps, tp, footer, }: { ps: ProviderSetupState; tp: string; footer?: ReactNode; }) { const { info, sel } = ps; const label = "block text-[12px] text-muted mt-3 mb-1"; const input = "w-full px-3 py-2 rounded-lg border bg-panel text-[13.5px] outline-none focus:border-accent"; const fieldsAll = info?.fields || []; const keyed = fieldsAll.some((x) => x.secret); // Cloud providers declare a segmented auth-method choice; the selected method's // credential fields render inside a panel with its own Test & save footer. const choice = fieldsAll.find((f) => f.choices && f.choices.length); const method = choice ? ps.fields[choice.key] || choice.default || "" : ""; const selected = choice?.choices?.find((c) => c.value === method); const methodFields = choice ? fieldsAll.filter( (f) => f.show_when && Object.entries(f.show_when).every(([k, v]) => (ps.fields[k] || "") === v), ) : []; // Without a choice control, Test lives next to the required secret (the API key), or // the first field for keyless providers (Ollama's Detect). const requiredSecret = fieldsAll.find((x) => x.secret && x.required); const testKey = requiredSecret ? requiredSecret.key : fieldsAll[0]?.key; if (!sel) return null; const fieldRow = (f: ProviderFieldT, testable: boolean) => (
ps.setFieldValue(f.key, e.target.value)} onBlur={f.secret ? undefined : () => void ps.saveField(f.key)} /> {ps.fieldSaved === f.key && ( ✓ Saved )} {/* §39: state lives IN the field — no status lines below. */} {ps.savedState && testable && ( {info?.needs_key ? <>✓ Tested & saved : <>✓ Detected} )}
{testable && ( )}
{f.help &&

{f.help}

}
); return (
{info?.title} {info ? ps.statusFor(info) : null}
{info?.blurb &&

{info.blurb}

} {fieldsAll .filter( (f) => !f.show_when && !(f.choices && f.choices.length) && !(f.key === "base_url" && keyed), ) .map((f) => fieldRow(f, !choice && f.key === testKey))} {/* Auth-method segmented control + the selected method's panel (owner call 2026-07-26): one joined track, then a soft inset card holding only that method's description, fields, and its own Test & save footer. */} {choice && (
{(choice.choices || []).map((c) => { const active = method === c.value; return ( ); })}
{selected?.desc &&

{selected.desc}

} {selected?.command && ( )} {methodFields.map((f) => fieldRow(f, false))}
{ps.savedState ? ( ✓ Tested & saved ) : ( Runs one read-only check, then saves. )}
)} {info?.needs_key && KEY_HELP[sel] && (

No key yet?{" "} {" "} — takes about a minute.

)} {info && !info.needs_key && (

No API key needed — Ollama runs models on this computer.{" "}

)} {/* Custom endpoint (keyed providers only): a quiet disclosure BELOW the key help, with enough separation to read as its own advanced row — no explainer copy (owner calls 2026-07-18 + 2026-07-19). */} {(() => { const keyed = (info?.fields || []).some((x) => x.secret); const ep = keyed ? (info?.fields || []).find((f) => f.key === "base_url") : undefined; if (!ep) return null; if (!ps.showEndpoint) return ( ); return (
ps.setFieldValue(ep.key, e.target.value)} onBlur={() => void ps.saveField(ep.key)} /> {ps.fieldSaved === ep.key && ( ✓ Saved )}
{ep.help &&

{ep.help}

}
); })()} {/* Error line: fixed height so failures never reflow the form. */}
{ps.verify.state === "error" && {ps.verify.msg}}
{footer}
); }