From f78d51dbbced51e37aeda5a5a707c9a321d88f8e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 5 May 2026 20:00:42 -0700 Subject: [PATCH] Use promiseExit in OAuth source UI --- .../plugins/mcp/src/react/AddMcpSource.tsx | 113 +++++++++--------- .../plugins/mcp/src/react/EditMcpSource.tsx | 35 +++--- packages/react/src/plugins/oauth-sign-in.tsx | 46 ++++--- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 35c8db9db..30d137a2f 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,5 +1,6 @@ import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; import { useScope } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; @@ -268,8 +269,8 @@ export default function AddMcpSource(props: { ); const scopeId = useScope(); - const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); - const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); + const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); + const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ @@ -333,24 +334,24 @@ export default function AddMcpSource(props: { const handleProbe = useCallback(async () => { dispatch({ type: "probe-start" }); - try { - const { headers, queryParams } = serializeHttpCredentials(remoteCredentials); - const result = await doProbe({ - params: { scopeId }, - payload: { - endpoint: state.url.trim(), - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), - }, - }); - setRemoteAuthMode(result.requiresOAuth ? "oauth2" : "none"); - dispatch({ type: "probe-ok", probe: result }); - } catch (e) { + const { headers, queryParams } = serializeHttpCredentials(remoteCredentials); + const exit = await doProbe({ + params: { scopeId }, + payload: { + endpoint: state.url.trim(), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), + }, + }); + if (Exit.isFailure(exit)) { dispatch({ type: "probe-fail", - error: e instanceof Error ? e.message : "Failed to connect", + error: "Failed to connect", }); + return; } + setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none"); + dispatch({ type: "probe-ok", probe: exit.value }); }, [state.url, scopeId, doProbe, remoteCredentials]); // Keep the latest handleProbe in a ref so the debounced effect can call it @@ -473,33 +474,32 @@ export default function AddMcpSource(props: { kind: "mcp", url: state.url.trim(), }); - try { - await doAdd({ - params: { scopeId }, - payload: { - transport: "remote" as const, - name: displayName, - namespace: slugNamespace || undefined, - endpoint: state.url.trim(), - auth, - ...(Object.keys(remoteRequestHeaders).length > 0 - ? { headers: remoteRequestHeaders } - : {}), - ...(Object.keys(credentials.queryParams).length > 0 - ? { queryParams: credentials.queryParams } - : {}), - }, - reactivityKeys: sourceWriteKeys, - }); - props.onComplete(); - } catch (e) { + const exit = await doAdd({ + params: { scopeId }, + payload: { + transport: "remote" as const, + name: displayName, + namespace: slugNamespace || undefined, + endpoint: state.url.trim(), + auth, + ...(Object.keys(remoteRequestHeaders).length > 0 + ? { headers: remoteRequestHeaders } + : {}), + ...(Object.keys(credentials.queryParams).length > 0 + ? { queryParams: credentials.queryParams } + : {}), + }, + reactivityKeys: sourceWriteKeys, + }); + placeholder.done(); + if (Exit.isFailure(exit)) { dispatch({ type: "add-fail", - error: e instanceof Error ? e.message : "Failed to add source", + error: "Failed to add source", }); - } finally { - placeholder.done(); + return; } + props.onComplete(); }, [ probe, remoteAuthMode, @@ -553,26 +553,25 @@ export default function AddMcpSource(props: { name: displayName, kind: "mcp", }); - try { - await doAdd({ - params: { scopeId }, - payload: { - transport: "stdio" as const, - name: displayName, - namespace: slugNamespace || undefined, - command: cmd, - args: parseStdioArgs(stdioArgs), - env: parseStdioEnv(stdioEnv), - }, - reactivityKeys: sourceWriteKeys, - }); - props.onComplete(); - } catch (e) { - setStdioError(e instanceof Error ? e.message : "Failed to add source"); + const exit = await doAdd({ + params: { scopeId }, + payload: { + transport: "stdio" as const, + name: displayName, + namespace: slugNamespace || undefined, + command: cmd, + args: parseStdioArgs(stdioArgs), + env: parseStdioEnv(stdioEnv), + }, + reactivityKeys: sourceWriteKeys, + }); + placeholder.done(); + if (Exit.isFailure(exit)) { + setStdioError("Failed to add source"); setStdioAdding(false); - } finally { - placeholder.done(); + return; } + props.onComplete(); }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); // ---- Render ---- diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 4ab5e6d07..e138b79d2 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; import { mcpSourceAtom, updateMcpSource } from "./atoms"; import { useScope } from "@executor-js/react/api/scope-context"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -35,7 +36,7 @@ function RemoteEditForm(props: { onSave: () => void; }) { const scopeId = useScope(); - const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); + const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); const secretList = useSecretPickerSecrets(); const identity = useSourceIdentity({ @@ -64,24 +65,24 @@ function RemoteEditForm(props: { setSaving(true); setError(null); const { headers, queryParams } = serializeHttpCredentials(credentials); - try { - await doUpdate({ - params: { scopeId, namespace: props.sourceId }, - payload: { - name: identity.name.trim() || undefined, - endpoint: endpoint.trim() || undefined, - headers, - queryParams, - }, - reactivityKeys: sourceWriteKeys, - }); - setDirty(false); - props.onSave(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to update source"); - } finally { + const exit = await doUpdate({ + params: { scopeId, namespace: props.sourceId }, + payload: { + name: identity.name.trim() || undefined, + endpoint: endpoint.trim() || undefined, + headers, + queryParams, + }, + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { + setError("Failed to update source"); setSaving(false); + return; } + setDirty(false); + setSaving(false); + props.onSave(); }; return ( diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx index c931df54d..8ed589146 100644 --- a/packages/react/src/plugins/oauth-sign-in.tsx +++ b/packages/react/src/plugins/oauth-sign-in.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; import { cancelOAuth, startOAuth } from "../api/atoms"; import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; @@ -80,8 +81,8 @@ export function useOAuthPopupFlow< startErrorMessage, } = options; const scopeId = useScope(); - const doStartOAuth = useAtomSet(startOAuth, { mode: "promise" }); - const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promise" }); + const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); + const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const cleanupRef = useRef<(() => void) | null>(null); @@ -92,7 +93,7 @@ export function useOAuthPopupFlow< void doCancelOAuth({ params: { scopeId }, payload: { sessionId }, - }).catch(() => undefined); + }); }, [doCancelOAuth, scopeId], ); @@ -122,6 +123,7 @@ export function useOAuthPopupFlow< cancel(); setBusy(true); setError(null); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller-provided Promise starts OAuth and reports a stable UI error try { const response = await input.run(); if (response.authorizationUrl === null) { @@ -151,11 +153,12 @@ export function useOAuthPopupFlow< return; } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller-provided success callback reports a stable UI error try { await input.onSuccess(result); setBusy(false); - } catch (e) { - const message = e instanceof Error ? e.message : "Failed to persist new connection"; + } catch { + const message = "Failed to persist new connection"; setBusy(false); setError(message); input.onError?.(message); @@ -182,9 +185,8 @@ export function useOAuthPopupFlow< input.onError?.(message); }, }); - } catch (e) { - const message = - e instanceof Error ? e.message : (startErrorMessage ?? "Failed to start sign-in"); + } catch { + const message = startErrorMessage ?? "Failed to start sign-in"; setBusy(false); setError(message); input.onError?.(message); @@ -203,21 +205,31 @@ export function useOAuthPopupFlow< const start = useCallback( async (input: StartOAuthPopupInput) => { + cancel(); + setBusy(true); + setError(null); + const exit = await doStartOAuth({ + params: { scopeId }, + payload: { + ...input.payload, + redirectUrl: input.payload.redirectUrl ?? oauthCallbackUrl(callbackPath), + }, + }); + if (Exit.isFailure(exit)) { + const message = startErrorMessage ?? "Failed to start sign-in"; + setBusy(false); + setError(message); + input.onError?.(message); + return; + } await openAuthorization({ onSuccess: input.onSuccess, onError: input.onError, onAuthorizationStarted: input.onAuthorizationStarted, - run: () => - doStartOAuth({ - params: { scopeId }, - payload: { - ...input.payload, - redirectUrl: input.payload.redirectUrl ?? oauthCallbackUrl(callbackPath), - }, - }), + run: async () => exit.value, }); }, - [callbackPath, doStartOAuth, openAuthorization, scopeId], + [callbackPath, cancel, doStartOAuth, openAuthorization, scopeId, startErrorMessage], ); return {