diff --git a/packages/react/src/api/oauth-popup.ts b/packages/react/src/api/oauth-popup.ts index b7e7dca66..a50fcb68a 100644 --- a/packages/react/src/api/oauth-popup.ts +++ b/packages/react/src/api/oauth-popup.ts @@ -85,6 +85,7 @@ export const openOAuthPopup = (input: OpenOAuthPopupInput): (() => /** Close the popup window if it's still open. Swallows cross-origin errors. */ const closePopup = (popup: Window | null) => { if (!popup) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin popup state can throw and cleanup is best-effort try { if (!popup.closed) popup.close(); } catch { @@ -129,6 +130,7 @@ export const openOAuthPopup = (input: OpenOAuthPopupInput): (() => const pollMs = input.closedPollMs ?? 500; pollHandle = setInterval(() => { let isClosed = false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser popup.closed can throw while navigating cross-origin try { isClosed = popup.closed; } catch { diff --git a/packages/react/src/api/optimistic.tsx b/packages/react/src/api/optimistic.tsx index 3af368adf..fa1ccddfb 100644 --- a/packages/react/src/api/optimistic.tsx +++ b/packages/react/src/api/optimistic.tsx @@ -176,7 +176,7 @@ export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => { if (!AsyncResult.isSuccess(result) || pending.length === 0) return; const serverIds = new Set( - result.value.map((connection: { readonly id: string }) => connection.id as string), + result.value.map((connection: { readonly id: string }) => connection.id), ); for (const entry of pending) { if (!serverIds.has(entry.id)) remove(entry.id); @@ -189,7 +189,7 @@ export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => { if (pending.length === 0) return connections; const hiddenIds = new Set(pending.map((entry) => entry.id)); return connections.filter( - (connection: { readonly id: string }) => !hiddenIds.has(connection.id as string), + (connection: { readonly id: string }) => !hiddenIds.has(connection.id), ); }), [result, pending], diff --git a/packages/react/src/components/form.tsx b/packages/react/src/components/form.tsx index 0830633aa..059dc187f 100644 --- a/packages/react/src/components/form.tsx +++ b/packages/react/src/components/form.tsx @@ -120,6 +120,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) { const { error, formMessageId } = useFormField(); + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: react-hook-form field errors carry public validation text const body = error ? String(error?.message ?? "") : props.children; if (!body) { diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index ca7bfe694..8b712f6d7 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; import { generateKeyBetween } from "fractional-indexing"; import { ChevronDownIcon } from "lucide-react"; import { PolicyId, type ToolPolicyAction } from "@executor-js/sdk"; @@ -253,7 +254,7 @@ export function PoliciesPage() { const scopeId = useScope(); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const doCreate = useAtomSet(createPolicyOptimistic(scopeId), { - mode: "promise", + mode: "promiseExit", }); const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise", @@ -265,15 +266,16 @@ export function PoliciesPage() { const handleCreate = async (input: { pattern: string; action: ToolPolicyAction }) => { setBusy(true); - try { - await doCreate({ - params: { scopeId }, - payload: { pattern: input.pattern, action: input.action }, - reactivityKeys: policyWriteKeys, - }); - } finally { + const exit = await doCreate({ + params: { scopeId }, + payload: { pattern: input.pattern, action: input.action }, + reactivityKeys: policyWriteKeys, + }); + if (Exit.isFailure(exit)) { setBusy(false); + return; } + setBusy(false); }; const handleUpdate = async (id: string, action: ToolPolicyAction) => { diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx index 7436b42cf..049d79037 100644 --- a/packages/react/src/pages/source-detail.tsx +++ b/packages/react/src/pages/source-detail.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; import { effectivePolicyFromSorted } from "@executor-js/sdk"; import { policiesOptimisticAtom, @@ -31,8 +32,8 @@ export function SourceDetailPage(props: { namespace: string }) { const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); - const doRemove = useAtomSet(removeSource, { mode: "promise" }); - const doRefresh = useAtomSet(refreshSource, { mode: "promise" }); + const doRemove = useAtomSet(removeSource, { mode: "promiseExit" }); + const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" }); const policyActions = usePolicyActions(scopeId); const navigate = useNavigate(); @@ -117,28 +118,25 @@ export function SourceDetailPage(props: { namespace: string }) { const handleDelete = async () => { setDeleting(true); - try { - await doRemove({ - params: { scopeId, sourceId: namespace }, - reactivityKeys: sourceWriteKeys, - }); - void navigate({ to: "/" }); - } catch { + const exit = await doRemove({ + params: { scopeId, sourceId: namespace }, + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { setDeleting(false); setConfirmDelete(false); + return; } + void navigate({ to: "/" }); }; const handleRefresh = async () => { setRefreshing(true); - try { - await doRefresh({ - params: { scopeId, sourceId: namespace }, - reactivityKeys: sourceWriteKeys, - }); - } finally { - setRefreshing(false); - } + await doRefresh({ + params: { scopeId, sourceId: namespace }, + reactivityKeys: sourceWriteKeys, + }); + setRefreshing(false); }; const handleEditSave = () => { diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index a69924588..c4e26fab6 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -2,6 +2,7 @@ import { Suspense, useCallback, useMemo, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; import { PlusIcon } from "lucide-react"; import type { SourceDetectionResult } from "@executor-js/sdk"; import { @@ -138,7 +139,7 @@ const looksLikeUrl = (raw: string): boolean => { function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { const sourcePlugins = useSourcePlugins(); const scopeId = useScope(); - const doDetect = useAtomSet(detectSource, { mode: "promise" }); + const doDetect = useAtomSet(detectSource, { mode: "promiseExit" }); const navigate = useNavigate(); const [query, setQuery] = useState(""); @@ -160,37 +161,38 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => if (!trimmed) return; setDetecting(true); setError(null); - try { - const results = await doDetect({ - params: { scopeId }, - payload: { url: trimmed }, - }); - if (results.length === 0) { - setError("Could not detect a source type from this URL. Try adding manually."); - setDetecting(false); - return; - } - const detected = bestDetection(results); - if (!detected) { - setError("Could not detect a source type from this URL. Try adding manually."); - setDetecting(false); - return; - } - const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind]; - if (pluginKey) { - closeAndReset(); - void navigate({ - to: "/sources/add/$pluginKey", - params: { pluginKey }, - search: { url: trimmed, namespace: detected.namespace }, - }); - } else { - setError(`Detected source type "${detected.kind}" but no plugin is available for it.`); - setDetecting(false); - } - } catch { + const exit = await doDetect({ + params: { scopeId }, + payload: { url: trimmed }, + }); + if (Exit.isFailure(exit)) { setError("Detection failed. Try adding a source manually."); setDetecting(false); + return; + } + const results = exit.value; + if (results.length === 0) { + setError("Could not detect a source type from this URL. Try adding manually."); + setDetecting(false); + return; + } + const detected = bestDetection(results); + if (!detected) { + setError("Could not detect a source type from this URL. Try adding manually."); + setDetecting(false); + return; + } + const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind]; + if (pluginKey) { + closeAndReset(); + void navigate({ + to: "/sources/add/$pluginKey", + params: { pluginKey }, + search: { url: trimmed, namespace: detected.namespace }, + }); + } else { + setError(`Detected source type "${detected.kind}" but no plugin is available for it.`); + setDetecting(false); } }, [query, doDetect, navigate, scopeId, closeAndReset]);