diff --git a/apps/web/src/domains/settings/ai/image-generation-card.tsx b/apps/web/src/domains/settings/ai/image-generation-card.tsx index 9a19accda2e..52d871cb123 100644 --- a/apps/web/src/domains/settings/ai/image-generation-card.tsx +++ b/apps/web/src/domains/settings/ai/image-generation-card.tsx @@ -1,5 +1,5 @@ import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -25,6 +25,7 @@ import { import { reconcileFromDaemonConfig } from "@/domains/settings/ai/ai-utils"; import { ServiceCard, SaveButton, ResetButton } from "@/domains/settings/ai/ai-shared-ui"; import { useAssistantId, useDaemonConfigQuery, useDaemonConfigMutation, useProvisionProviderKey } from "@/domains/settings/ai/use-daemon-config"; +import { useDraftOverride } from "@/domains/settings/ai/use-draft-override"; import { modelImagegenPut } from "@/generated/daemon/sdk.gen"; export function ImageGenerationCard() { @@ -41,17 +42,7 @@ export function ImageGenerationCard() { return reconciled.imageGenMode ?? (getLocalSetting(LS_IMAGE_GEN_MODE, "your-own") as ServiceMode); }, [daemonConfig]); - // Draft override — null means the user hasn't changed the value yet. - const [draftImageGenMode, setDraftImageGenMode] = useState(null); - - // Auto-clear draft once the server value catches up after save. - useEffect(() => { - if (draftImageGenMode !== null && serverImageGenMode === draftImageGenMode) { - setDraftImageGenMode(null); - } - }, [serverImageGenMode, draftImageGenMode]); - - const imageGenMode = draftImageGenMode ?? serverImageGenMode; + const [imageGenMode, setDraftImageGenMode] = useDraftOverride(serverImageGenMode); const [imageGenModel, setImageGenModel] = useState(() => getLocalSetting(LS_IMAGE_GEN_MODEL, "gemini-3.1-flash-image-preview"), diff --git a/apps/web/src/domains/settings/ai/language-model-card.tsx b/apps/web/src/domains/settings/ai/language-model-card.tsx index 84dd41ccb19..7e5d585f36e 100644 --- a/apps/web/src/domains/settings/ai/language-model-card.tsx +++ b/apps/web/src/domains/settings/ai/language-model-card.tsx @@ -10,6 +10,7 @@ import { useAssistantFeatureFlagStore } from "@/stores/assistant-feature-flag-st import { ByoServiceCard, SaveButton } from "@/domains/settings/ai/ai-shared-ui"; import { useDaemonConfigQuery, useDaemonConfigMutation } from "@/domains/settings/ai/use-daemon-config"; +import { useDraftOverride } from "@/domains/settings/ai/use-draft-override"; import { CallSiteOverridesModal } from "@/domains/settings/ai/call-site-overrides-modal"; import { ManageProfilesModal } from "@/domains/settings/ai/manage-profiles-modal"; import { ManageProvidersModal } from "@/domains/settings/ai/manage-providers-modal"; @@ -29,22 +30,7 @@ export function LanguageModelCard() { } = useDaemonConfigQuery(); const configMutation = useDaemonConfigMutation(); - // Draft active profile — ephemeral UI state for the unsaved dropdown - // selection. Resets to the server value when the server value changes - // (i.e. after a successful save + cache invalidation). - const [draftActiveProfile, setDraftActiveProfile] = useState(null); - const [draftInitialized, setDraftInitialized] = useState(false); - - // Derive the active profile to display: when the user hasn't touched - // the dropdown yet, use the server value; otherwise use the draft. - // After a successful save, `draftInitialized` resets so the dropdown - // re-syncs to the persisted value. - const effectiveActiveProfile = useMemo(() => { - if (!draftInitialized && activeProfile !== null) { - return activeProfile; - } - return draftActiveProfile ?? activeProfile; - }, [draftInitialized, draftActiveProfile, activeProfile]); + const [effectiveActiveProfile, setDraftActiveProfile] = useDraftOverride(activeProfile); // Modal toggles — ephemeral UI state, correct as useState const [manageProfilesOpen, setManageProfilesOpen] = useState(false); @@ -73,8 +59,6 @@ export function LanguageModelCard() { const handleManagedProfileSave = useCallback(async () => { try { await configMutation.mutateAsync({ llm: { activeProfile: effectiveActiveProfile } }); - setDraftInitialized(false); - setDraftActiveProfile(null); toast.success("Profile saved."); } catch (error) { toast.error("Failed to switch profile. Please try again."); @@ -96,7 +80,6 @@ export function LanguageModelCard() { { - setDraftInitialized(true); setDraftActiveProfile(val === "" ? null : val); }} placeholder="Select a default profile…" diff --git a/apps/web/src/domains/settings/ai/provider-editor-modal.tsx b/apps/web/src/domains/settings/ai/provider-editor-modal.tsx index b495d1d9ea8..9c64f6d1e79 100644 --- a/apps/web/src/domains/settings/ai/provider-editor-modal.tsx +++ b/apps/web/src/domains/settings/ai/provider-editor-modal.tsx @@ -13,7 +13,6 @@ import { inferenceProviderconnectionsPost, secretsGet, secretsPost, - secretsReadPost, } from "@/generated/daemon/sdk.gen"; import { ApiError, @@ -23,6 +22,7 @@ import { import { shouldRetryDaemonError } from "@/utils/daemon-errors"; import { captureError } from "@/lib/sentry/capture-error"; import { useIsOrgReady } from "@/hooks/use-is-org-ready"; +import { useStoredCredentialPresence } from "@/domains/settings/ai/use-stored-credential-presence"; import { ChatgptOAuthSection } from "@/domains/settings/ai/chatgpt-oauth-section"; import { @@ -42,7 +42,6 @@ import { providerSupportsPlatformAuth } from "@/assistant/llm-model-catalog"; // Query keys // --------------------------------------------------------------------------- -const PROVIDER_CREDENTIAL_PRESENCE_QK = "provider-credential-presence" as const; const PROVIDER_CREDENTIALS_LIST_QK = "provider-credentials-list" as const; function parseCredentialRef(credRef: string): { service: string; field: string } | null { @@ -199,48 +198,22 @@ export function ProviderEditorContent({ const queryClient = useQueryClient(); const isOrgReady = useIsOrgReady(); - // --- Credential presence query (TanStack Query) --- + // --- Credential presence (shared hook) --- const parsedCredRef = useMemo(() => parseCredentialRef(credential), [credential]); const needsCredentialCheck = authType === "api_key" && parsedCredRef !== null; - const credentialPresenceKey = useMemo( - () => [PROVIDER_CREDENTIAL_PRESENCE_QK, assistantId, parsedCredRef?.service ?? "", parsedCredRef?.field ?? ""], - [assistantId, parsedCredRef], - ); - - const credentialPresenceQuery = useQuery({ + const { + hasStoredCredential, + isLoading: isLoadingCredential, queryKey: credentialPresenceKey, - queryFn: async () => { - const { data, error, response } = await secretsReadPost({ - path: { assistant_id: assistantId }, - body: { type: "credential", name: `${parsedCredRef!.service}:${parsedCredRef!.field}` }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to check stored credential"); - if (!response.ok) { - throw new ApiError( - response.status, - extractErrorMessage(error, response, `Failed to check stored credential (HTTP ${response.status})`), - ); - } - return data!.found; - }, - enabled: !!assistantId && needsCredentialCheck && isOrgReady, - retry: shouldRetryDaemonError, - staleTime: 30_000, + } = useStoredCredentialPresence({ + assistantId, + credentialKind: "credential", + credentialName: parsedCredRef ? `${parsedCredRef.service}:${parsedCredRef.field}` : "", + enabled: needsCredentialCheck, + errorContext: "settings-provider-editor-credential-presence", }); - useEffect(() => { - if (!credentialPresenceQuery.error) return; - captureError(credentialPresenceQuery.error, { - context: "settings-provider-editor-credential-presence", - bestEffort: true, - }); - }, [credentialPresenceQuery.error]); - - const hasStoredCredential = credentialPresenceQuery.data ?? false; - const isLoadingCredential = credentialPresenceQuery.isLoading && needsCredentialCheck; - // --- Available credentials list query (TanStack Query) --- const needsCredentialsList = authType === "api_key" || effectiveMode === "create"; diff --git a/apps/web/src/domains/settings/ai/use-draft-override.ts b/apps/web/src/domains/settings/ai/use-draft-override.ts new file mode 100644 index 00000000000..c52d659806f --- /dev/null +++ b/apps/web/src/domains/settings/ai/use-draft-override.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Manages a local draft that overrides a server-derived value. + * + * Returns `[effectiveValue, setDraft]` where `effectiveValue` is the + * draft when set, otherwise the server value. The draft auto-clears + * when the server value converges (e.g. after a save + cache refetch), + * preventing the UI from briefly reverting to stale server state during + * the refetch window. + * + * Pass `undefined` to clear the draft (revert to server value). + * Any `T` value — including `null` — is stored as a valid draft. + */ +export function useDraftOverride(serverValue: T): [T, (draft: T | undefined) => void] { + const [draft, setDraft] = useState<{ value: T } | undefined>(undefined); + + useEffect(() => { + if (draft !== undefined && serverValue === draft.value) { + setDraft(undefined); + } + }, [serverValue, draft]); + + const effective = draft !== undefined ? draft.value : serverValue; + const updateDraft = useCallback( + (d: T | undefined) => setDraft(d === undefined ? undefined : { value: d }), + [], + ); + return [effective, updateDraft]; +} diff --git a/apps/web/src/domains/settings/ai/use-stored-credential-presence.ts b/apps/web/src/domains/settings/ai/use-stored-credential-presence.ts new file mode 100644 index 00000000000..c92ba60f683 --- /dev/null +++ b/apps/web/src/domains/settings/ai/use-stored-credential-presence.ts @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { secretsReadPost } from "@/generated/daemon/sdk.gen"; +import { ApiError, assertHasResponse, extractErrorMessage } from "@/utils/api-errors"; +import { shouldRetryDaemonError } from "@/utils/daemon-errors"; +import { captureError } from "@/lib/sentry/capture-error"; +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; + +// --------------------------------------------------------------------------- +// Query key +// --------------------------------------------------------------------------- + +const STORED_CREDENTIAL_PRESENCE_QK = "stored-credential-presence" as const; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +interface UseStoredCredentialPresenceOptions { + assistantId: string | undefined; + /** Credential kind sent to the daemon (e.g. "api_key", "credential"). */ + credentialKind: string; + /** Credential identifier sent to the daemon (e.g. "tavily", "anthropic:api_key"). */ + credentialName: string; + /** Extra guard — the query only fires when all conditions are true. */ + enabled?: boolean; + /** Sentry context tag for error reporting. */ + errorContext: string; +} + +/** + * Checks whether a stored credential exists on the daemon. + * + * Wraps `secretsReadPost` in a TanStack Query hook with org-readiness + * gating, retry logic for transient daemon errors, and Sentry reporting + * for persistent failures. + */ +export function useStoredCredentialPresence({ + assistantId, + credentialKind, + credentialName, + enabled = true, + errorContext, +}: UseStoredCredentialPresenceOptions) { + const isOrgReady = useIsOrgReady(); + + const queryKey = useMemo( + () => [STORED_CREDENTIAL_PRESENCE_QK, assistantId ?? "", credentialKind, credentialName] as const, + [assistantId, credentialKind, credentialName], + ); + + const query = useQuery({ + queryKey, + queryFn: async () => { + const { data, error, response } = await secretsReadPost({ + path: { assistant_id: assistantId! }, + body: { type: credentialKind, name: credentialName }, + throwOnError: false, + }); + assertHasResponse(response, error, "Failed to check stored credential"); + if (!response.ok) { + throw new ApiError( + response.status, + extractErrorMessage( + error, + response, + `Failed to check stored credential (HTTP ${response.status})`, + ), + ); + } + return data!.found; + }, + enabled: !!assistantId && enabled && isOrgReady, + retry: shouldRetryDaemonError, + staleTime: 30_000, + }); + + useEffect(() => { + if (!query.error) return; + captureError(query.error, { context: errorContext, bestEffort: true }); + }, [query.error, errorContext]); + + return { + hasStoredCredential: query.data ?? false, + isLoading: query.isLoading, + queryKey, + }; +} diff --git a/apps/web/src/domains/settings/ai/web-search-card.tsx b/apps/web/src/domains/settings/ai/web-search-card.tsx index 3d5b10ff1ac..438da4acc1c 100644 --- a/apps/web/src/domains/settings/ai/web-search-card.tsx +++ b/apps/web/src/domains/settings/ai/web-search-card.tsx @@ -1,7 +1,7 @@ import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Dropdown } from "@vellum/design-library/components/dropdown"; import { Input } from "@vellum/design-library/components/input"; import { toast } from "@vellum/design-library/components/toast"; @@ -12,14 +12,6 @@ import { WEB_SEARCH_PROVIDER_KEY_PLACEHOLDERS, } from "@/assistant/generated/web-search-provider-catalog.gen"; import { captureError } from "@/lib/sentry/capture-error"; -import { secretsReadPost } from "@/generated/daemon/sdk.gen"; -import { - ApiError, - assertHasResponse, - extractErrorMessage, -} from "@/utils/api-errors"; -import { shouldRetryDaemonError } from "@/utils/daemon-errors"; -import { useIsOrgReady } from "@/hooks/use-is-org-ready"; import { getLocalSetting, removeLocalSetting, @@ -32,19 +24,8 @@ import { LS_WEB_SEARCH_MODE, LS_WEB_SEARCH_PROVIDER } from "@/domains/settings/a import { getWebSearchProviderKeyStorage, reconcileFromDaemonConfig } from "@/domains/settings/ai/ai-utils"; import { ServiceCard, SaveButton, ResetButton } from "@/domains/settings/ai/ai-shared-ui"; import { useDaemonConfigQuery, useDaemonConfigMutation, useProvisionProviderKey } from "@/domains/settings/ai/use-daemon-config"; - -// --------------------------------------------------------------------------- -// Query key for the stored-credential presence check -// --------------------------------------------------------------------------- - -const WEB_SEARCH_CREDENTIAL_QK = "web-search-credential" as const; - -function webSearchCredentialQueryKey( - assistantId: string | null | undefined, - provider: string, -) { - return [WEB_SEARCH_CREDENTIAL_QK, assistantId ?? "", provider] as const; -} +import { useDraftOverride } from "@/domains/settings/ai/use-draft-override"; +import { useStoredCredentialPresence } from "@/domains/settings/ai/use-stored-credential-presence"; export function WebSearchCard() { const { @@ -58,86 +39,35 @@ export function WebSearchCard() { // Server values derived from daemon config, falling back to localStorage. // When the cache refreshes (after save + invalidation), these update // automatically. - const serverWebSearchMode = useMemo(() => { - if (!daemonConfig) return getLocalSetting(LS_WEB_SEARCH_MODE, "your-own") as ServiceMode; - const reconciled = reconcileFromDaemonConfig(daemonConfig); - return reconciled.webSearchMode ?? (getLocalSetting(LS_WEB_SEARCH_MODE, "your-own") as ServiceMode); - }, [daemonConfig]); - - const serverWebSearchProvider = useMemo(() => { - if (!daemonConfig) return getLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"); + const { serverWebSearchMode, serverWebSearchProvider } = useMemo(() => { + if (!daemonConfig) { + return { + serverWebSearchMode: getLocalSetting(LS_WEB_SEARCH_MODE, "your-own") as ServiceMode, + serverWebSearchProvider: getLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"), + }; + } const reconciled = reconcileFromDaemonConfig(daemonConfig); - return reconciled.webSearchProvider ?? getLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"); + return { + serverWebSearchMode: (reconciled.webSearchMode ?? getLocalSetting(LS_WEB_SEARCH_MODE, "your-own")) as ServiceMode, + serverWebSearchProvider: reconciled.webSearchProvider ?? getLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"), + }; }, [daemonConfig]); - // Draft overrides — null means the user hasn't changed the value yet. const [saving, setSaving] = useState(false); - const [draftWebSearchMode, setDraftWebSearchMode] = useState(null); - const [draftWebSearchProvider, setDraftWebSearchProvider] = useState(null); - - // Auto-clear drafts once the server value catches up after save. - // Avoids a UI flicker where the mode toggle briefly reverts to the - // stale server value during the cache refetch window. - useEffect(() => { - if (draftWebSearchMode !== null && serverWebSearchMode === draftWebSearchMode) { - setDraftWebSearchMode(null); - } - }, [serverWebSearchMode, draftWebSearchMode]); - - useEffect(() => { - if (draftWebSearchProvider !== null && serverWebSearchProvider === draftWebSearchProvider) { - setDraftWebSearchProvider(null); - } - }, [serverWebSearchProvider, draftWebSearchProvider]); - - // Effective values: user draft takes precedence over server. - const webSearchMode = draftWebSearchMode ?? serverWebSearchMode; - const webSearchProvider = draftWebSearchProvider ?? serverWebSearchProvider; + const [webSearchMode, setDraftWebSearchMode] = useDraftOverride(serverWebSearchMode); + const [webSearchProvider, setDraftWebSearchProvider] = useDraftOverride(serverWebSearchProvider); const [webSearchApiKey, setWebSearchApiKey] = useState(""); - // --- Secret presence query (TanStack Query) --- - const isOrgReady = useIsOrgReady(); const requiresProviderCredential = WEB_SEARCH_BYOK_PROVIDER_IDS.has(webSearchProvider); - - const credentialQueryKey = useMemo( - () => webSearchCredentialQueryKey(assistantId, webSearchProvider), - [assistantId, webSearchProvider], - ); - - const credentialQuery = useQuery({ - queryKey: credentialQueryKey, - queryFn: async () => { - const { data, error, response } = await secretsReadPost({ - path: { assistant_id: assistantId! }, - body: { type: "api_key", name: webSearchProvider }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to check stored key"); - if (!response.ok) { - throw new ApiError( - response.status, - extractErrorMessage(error, response, `Failed to check stored key (HTTP ${response.status})`), - ); - } - return data!.found; - }, - enabled: !!assistantId && requiresProviderCredential && isOrgReady, - retry: shouldRetryDaemonError, - staleTime: 30_000, - }); - - // Defense-in-depth: if retries exhaust on an expected transient error, - // suppress the Sentry report rather than creating noise. - useEffect(() => { - if (!credentialQuery.error) return; - captureError(credentialQuery.error, { - context: "settings-ai-web-search-read-credential", - bestEffort: true, + const { hasStoredCredential: webSearchHasStoredKey, queryKey: credentialQueryKey } = + useStoredCredentialPresence({ + assistantId, + credentialKind: "api_key", + credentialName: webSearchProvider, + enabled: requiresProviderCredential, + errorContext: "settings-ai-web-search-read-credential", }); - }, [credentialQuery.error]); - - const webSearchHasStoredKey = credentialQuery.data ?? false; // --- Derived state --- const hasNewApiKey = webSearchApiKey.trim().length > 0; @@ -193,10 +123,7 @@ export function WebSearchCard() { } // Optimistic update: mark key as stored immediately, then // background-refetch confirms server state. - queryClient.setQueryData( - webSearchCredentialQueryKey(assistantId, providerToSave), - true, - ); + queryClient.setQueryData(credentialQueryKey, true); void queryClient.invalidateQueries({ queryKey: credentialQueryKey }); setWebSearchApiKey(""); } @@ -206,7 +133,6 @@ export function WebSearchCard() { toast.error("Saved, but local preferences could not be written."); } }, [ - assistantId, requiresProviderCredential, configMutation, provisionProviderKey, @@ -225,7 +151,7 @@ export function WebSearchCard() { setWebSearchApiKey(""); setDraftWebSearchProvider("inference-provider-native"); setLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"); - }, [webSearchProvider]); + }, [webSearchProvider, setDraftWebSearchProvider]); return (