From 4c7e63cf7abfda70df7a9f4ed027599bfa8b91f6 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:00:50 -0700 Subject: [PATCH 1/8] improvement(settings): SSR prefetch, code splitting, dedicated skeletons --- .../app/_shell/providers/get-query-client.ts | 38 +++++ .../app/_shell/providers/query-provider.tsx | 36 +---- .../settings/[section]/error.tsx | 23 +++ .../[workspaceId]/settings/[section]/page.tsx | 14 +- .../settings/[section]/prefetch.ts | 75 ++++++++++ .../settings/[section]/settings.tsx | 141 +++++++++++++++--- .../components/api-keys/api-key-skeleton.tsx | 31 ++++ .../settings/components/api-keys/api-keys.tsx | 16 +- .../components/byok/byok-skeleton.tsx | 32 ++++ .../settings/components/byok/byok.tsx | 17 +-- .../components/copilot/copilot-skeleton.tsx | 14 ++ .../settings/components/copilot/copilot.tsx | 24 +-- .../credential-sets-skeleton.tsx | 37 +++++ .../credential-sets/credential-sets.tsx | 34 +---- .../credentials/credential-skeleton.tsx | 32 ++++ .../credentials/credentials-manager.tsx | 16 +- .../custom-tools/custom-tool-skeleton.tsx | 32 ++++ .../components/custom-tools/custom-tools.tsx | 18 +-- .../components/debug/debug-skeleton.tsx | 13 ++ .../settings/components/debug/debug.tsx | 14 +- .../components/general/general-skeleton.tsx | 63 ++++++++ .../settings/components/general/general.tsx | 74 +-------- .../settings/components/index.ts | 14 -- .../settings/components/mcp/mcp-skeleton.tsx | 14 ++ .../components/skills/skill-skeleton.tsx | 14 ++ .../settings/components/skills/skills.tsx | 18 +-- .../workflow-mcp-servers-skeleton.tsx | 6 + .../settings-sidebar/settings-sidebar.tsx | 36 ++++- apps/sim/hooks/queries/credentials.ts | 28 ++++ apps/sim/hooks/queries/general-settings.ts | 13 ++ apps/sim/hooks/queries/subscription.ts | 19 ++- 31 files changed, 667 insertions(+), 289 deletions(-) create mode 100644 apps/sim/app/_shell/providers/get-query-client.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/error.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton.tsx diff --git a/apps/sim/app/_shell/providers/get-query-client.ts b/apps/sim/app/_shell/providers/get-query-client.ts new file mode 100644 index 0000000000..8d7d2ecbb7 --- /dev/null +++ b/apps/sim/app/_shell/providers/get-query-client.ts @@ -0,0 +1,38 @@ +import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query' + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + retryOnMount: false, + }, + mutations: { + retry: 1, + }, + dehydrate: { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || query.state.status === 'pending', + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined + +/** + * Returns a QueryClient instance. On the server, creates a new instance per request. + * On the client, reuses a singleton instance. + */ +export function getQueryClient() { + if (isServer) { + return makeQueryClient() + } + if (!browserQueryClient) { + browserQueryClient = makeQueryClient() + } + return browserQueryClient +} diff --git a/apps/sim/app/_shell/providers/query-provider.tsx b/apps/sim/app/_shell/providers/query-provider.tsx index ea286d5f75..a7f19898c8 100644 --- a/apps/sim/app/_shell/providers/query-provider.tsx +++ b/apps/sim/app/_shell/providers/query-provider.tsx @@ -1,40 +1,10 @@ 'use client' import type { ReactNode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' -/** - * Singleton QueryClient instance for client-side use. - * Can be imported directly for cache operations outside React components. - */ -function makeQueryClient() { - return new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - retryOnMount: false, - }, - mutations: { - retry: 1, - }, - }, - }) -} - -let browserQueryClient: QueryClient | undefined - -export function getQueryClient() { - if (typeof window === 'undefined') { - return makeQueryClient() - } - if (!browserQueryClient) { - browserQueryClient = makeQueryClient() - } - return browserQueryClient -} +export { getQueryClient } from '@/app/_shell/providers/get-query-client' export function QueryProvider({ children }: { children: ReactNode }) { const queryClient = getQueryClient() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/error.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/error.tsx new file mode 100644 index 0000000000..10943828e1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/error.tsx @@ -0,0 +1,23 @@ +'use client' + +import { ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function SettingsSectionError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 8a8573987b..ddad60527e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -1,5 +1,8 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { prefetchGeneralSettings, prefetchUserProfile } from './prefetch' import { SettingsPage } from './settings' const SECTION_TITLES: Record = { @@ -36,5 +39,14 @@ export default async function SettingsSectionPage({ params: Promise<{ workspaceId: string; section: string }> }) { const { section } = await params - return + const queryClient = getQueryClient() + + void prefetchGeneralSettings(queryClient) + void prefetchUserProfile(queryClient) + + return ( + + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts new file mode 100644 index 0000000000..b39acf3bf4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -0,0 +1,75 @@ +import type { QueryClient } from '@tanstack/react-query' +import { headers } from 'next/headers' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { generalSettingsKeys } from '@/hooks/queries/general-settings' +import { userProfileKeys } from '@/hooks/queries/user-profile' + +/** + * Forwards incoming request cookies so server-side API fetches authenticate correctly. + */ +async function getForwardedHeaders(): Promise> { + const h = await headers() + const cookie = h.get('cookie') + return cookie ? { cookie } : {} +} + +/** + * Prefetch general settings server-side via internal API fetch. + * Uses the same query keys as the client `useGeneralSettings` hook + * so data is shared via HydrationBoundary. + */ +export function prefetchGeneralSettings(queryClient: QueryClient) { + return queryClient.prefetchQuery({ + queryKey: generalSettingsKeys.settings(), + queryFn: async () => { + const fwdHeaders = await getForwardedHeaders() + const baseUrl = getInternalApiBaseUrl() + const response = await fetch(`${baseUrl}/api/users/me/settings`, { + headers: fwdHeaders, + }) + if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`) + const { data } = await response.json() + return { + autoConnect: data.autoConnect ?? true, + showTrainingControls: data.showTrainingControls ?? false, + superUserModeEnabled: data.superUserModeEnabled ?? true, + theme: data.theme || 'system', + telemetryEnabled: data.telemetryEnabled ?? true, + billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true, + errorNotificationsEnabled: data.errorNotificationsEnabled ?? true, + snapToGridSize: data.snapToGridSize ?? 0, + showActionBar: data.showActionBar ?? true, + } + }, + staleTime: 60 * 60 * 1000, + }) +} + +/** + * Prefetch user profile server-side via internal API fetch. + * Uses the same query keys as the client `useUserProfile` hook + * so data is shared via HydrationBoundary. + */ +export function prefetchUserProfile(queryClient: QueryClient) { + return queryClient.prefetchQuery({ + queryKey: userProfileKeys.profile(), + queryFn: async () => { + const fwdHeaders = await getForwardedHeaders() + const baseUrl = getInternalApiBaseUrl() + const response = await fetch(`${baseUrl}/api/users/me/profile`, { + headers: fwdHeaders, + }) + if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`) + const { user } = await response.json() + return { + id: user.id, + name: user.name || '', + email: user.email || '', + image: user.image || null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 901c5f4799..877858e767 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -1,29 +1,134 @@ 'use client' +import dynamic from 'next/dynamic' import { useSearchParams } from 'next/navigation' -import { - ApiKeys, - BYOK, - Copilot, - CredentialSets, - Credentials, - CustomTools, - Debug, - General, - MCP, - Skills, - Subscription, - TeamManagement, - TemplateProfile, - WorkflowMcpServers, -} from '@/app/workspace/[workspaceId]/settings/components' +import { Skeleton } from '@/components/emcn' +import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton' +import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' +import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton' +import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton' +import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton' +import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton' +import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton' +import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton' +import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton' +import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton' +import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { allNavigationItems, isBillingEnabled, } from '@/app/workspace/[workspaceId]/settings/navigation' -import { AccessControl } from '@/ee/access-control/components/access-control' -import { SSO } from '@/ee/sso/components/sso-settings' + +/** + * Generic skeleton fallback for sections without a dedicated skeleton. + */ +function SettingsSectionSkeleton() { + return ( +
+ + + + +
+ ) +} + +const General = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/general/general').then( + (m) => m.General + ), + { loading: () => } +) +const Credentials = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials').then( + (m) => m.Credentials + ), + { loading: () => } +) +const TemplateProfile = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/template-profile/template-profile' + ).then((m) => m.TemplateProfile), + { loading: () => } +) +const CredentialSets = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets' + ).then((m) => m.CredentialSets), + { loading: () => } +) +const ApiKeys = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( + (m) => m.ApiKeys + ), + { loading: () => } +) +const Subscription = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription').then( + (m) => m.Subscription + ), + { loading: () => } +) +const TeamManagement = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/team-management/team-management' + ).then((m) => m.TeamManagement), + { loading: () => } +) +const BYOK = dynamic( + () => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK), + { loading: () => } +) +const Copilot = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then( + (m) => m.Copilot + ), + { loading: () => } +) +const MCP = dynamic( + () => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP), + { loading: () => } +) +const CustomTools = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( + (m) => m.CustomTools + ), + { loading: () => } +) +const Skills = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/skills/skills').then((m) => m.Skills), + { loading: () => } +) +const WorkflowMcpServers = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' + ).then((m) => m.WorkflowMcpServers), + { loading: () => } +) +const Debug = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug), + { loading: () => } +) +const AccessControl = dynamic( + () => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl), + { loading: () => } +) +const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), { + loading: () => , +}) interface SettingsPageProps { section: SettingsSection diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx new file mode 100644 index 0000000000..5c2008c381 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton component for API key list items. + */ +export function ApiKeySkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+ ) +} + +/** + * Skeleton for the API Keys section shown during dynamic import loading. + */ +export function ApiKeysSkeleton() { + return ( +
+ + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index c98c059375..1e96cecf7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -18,6 +18,7 @@ import { Input, Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { formatDate } from '@/lib/core/utils/formatting' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { ApiKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton' import { type ApiKey, useApiKeys, @@ -408,18 +409,3 @@ export function ApiKeys() { ) } - -function ApiKeySkeleton() { - return ( -
-
-
- - -
- -
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx new file mode 100644 index 0000000000..53d0009fa8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton component for BYOK provider key items. + */ +export function BYOKKeySkeleton() { + return ( +
+
+ +
+ + +
+
+ +
+ ) +} + +/** + * Skeleton for the BYOK section shown during dynamic import loading. + */ +export function BYOKSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 63e90c43b3..b5df541858 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -30,7 +30,7 @@ import { PerplexityIcon, SerperIcon, } from '@/components/icons' -import { Skeleton } from '@/components/ui' +import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { type BYOKKey, useBYOKKeys, @@ -155,21 +155,6 @@ const PROVIDERS: { }, ] -function BYOKKeySkeleton() { - return ( -
-
- -
- - -
-
- -
- ) -} - export function BYOK() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton.tsx new file mode 100644 index 0000000000..000b1e0269 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton.tsx @@ -0,0 +1,14 @@ +import { + ApiKeySkeleton, + ApiKeysSkeleton, +} from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton' + +/** + * Re-export ApiKeySkeleton as CopilotKeySkeleton since both share identical markup. + */ +export const CopilotKeySkeleton = ApiKeySkeleton + +/** + * Skeleton for the Copilot section shown during dynamic import loading. + */ +export const CopilotSkeleton = ApiKeysSkeleton diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index a3c2829bd9..22b8cfd184 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -14,8 +14,10 @@ import { ModalHeader, // Switch, } from '@/components/emcn' -import { Input, Skeleton } from '@/components/ui' +import { Input } from '@/components/ui' import { formatDate } from '@/lib/core/utils/formatting' +// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp' +import { CopilotKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton' import { type CopilotKey, useCopilotKeys, @@ -23,28 +25,8 @@ import { useGenerateCopilotKey, } from '@/hooks/queries/copilot-keys' -// import { useMcpServers, useUpdateMcpServer } from '@/hooks/queries/mcp' - const logger = createLogger('CopilotSettings') -/** - * Skeleton component for loading state of copilot key items - */ -function CopilotKeySkeleton() { - return ( -
-
-
- - -
- -
- -
- ) -} - /** * Copilot Keys management component for handling API keys used with the Copilot feature. * Provides functionality to create, view, and delete copilot API keys. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx new file mode 100644 index 0000000000..fed88f080d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx @@ -0,0 +1,37 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton for the Credential Sets (Email Polling) section shown during dynamic import loading. + */ +export function CredentialSetsSkeleton() { + return ( +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index b84273889d..e13b02fc04 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -29,6 +29,7 @@ import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-s import { quickValidateEmail } from '@/lib/messaging/email/validation' import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' +import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton' import { type CredentialSet, useAcceptCredentialSetInvitation, @@ -50,39 +51,6 @@ import { useSubscriptionData } from '@/hooks/queries/subscription' const logger = createLogger('EmailPolling') -function CredentialSetsSkeleton() { - return ( -
-
- -
-
- -
- - -
-
- -
-
-
- -
-
- -
- - -
-
- -
-
-
- ) -} - export function CredentialSets() { const { data: session } = useSession() const { data: organizationsData } = useOrganizations() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx new file mode 100644 index 0000000000..6312b570a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton component for credential list items. + */ +export function CredentialSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +/** + * Skeleton for the Credentials section shown during dynamic import loading. + */ +export function CredentialsSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index bd3c3d59ca..4d21954fb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -33,6 +33,7 @@ import { getServiceConfigByProviderId, type OAuthProvider, } from '@/lib/oauth' +import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { isValidEnvVarName } from '@/executor/constants' import { @@ -159,21 +160,6 @@ function normalizeEnvKeyInput(raw: string): string { return wrappedMatch ? wrappedMatch[1] : trimmed } -function CredentialSkeleton() { - return ( -
-
- - -
-
- - -
-
- ) -} - export function CredentialsManager() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx new file mode 100644 index 0000000000..0a42850887 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton component for custom tool list items. + */ +export function CustomToolSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +/** + * Skeleton for the Custom Tools section shown during dynamic import loading. + */ +export function CustomToolsSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index b456d72243..3a1aa93efd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -5,27 +5,13 @@ import { createLogger } from '@sim/logger' import { Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' -import { Input, Skeleton } from '@/components/ui' +import { Input } from '@/components/ui' +import { CustomToolSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton' import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' const logger = createLogger('CustomToolsSettings') -function CustomToolSkeleton() { - return ( -
-
- - -
-
- - -
-
- ) -} - export function CustomTools() { const params = useParams() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx new file mode 100644 index 0000000000..9a59ad7b1f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton for the Debug section shown during dynamic import loading. + */ +export function DebugSkeleton() { + return ( +
+ + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug.tsx index 5d73d31e5c..86fc826b06 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { useParams } from 'next/navigation' import { Button, Input as EmcnInput } from '@/components/emcn' -import { Skeleton } from '@/components/ui' +import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton' import { useImportWorkflow } from '@/hooks/queries/workflows' /** @@ -73,15 +73,3 @@ export function Debug() { ) } - -/** - * Loading skeleton displayed during workflow import. - */ -function DebugSkeleton() { - return ( -
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx new file mode 100644 index 0000000000..2aa5b22ad9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from '@/components/ui' + +/** + * Skeleton component for general settings loading state. + * Matches the exact layout structure of the General component. + */ +export function GeneralSkeleton() { + return ( +
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 5861dc60dc..ba5fba54ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -16,12 +16,13 @@ import { ModalHeader, Switch, } from '@/components/emcn' -import { Input, Skeleton } from '@/components/ui' +import { Input } from '@/components/ui' import { signOut, useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' +import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton' import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload' import { useBrandConfig } from '@/ee/whitelabeling' import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' @@ -49,77 +50,6 @@ function getInitials(name: string | undefined | null): string { return parts[0][0].toUpperCase() } -/** - * Skeleton component for general settings loading state. - * Matches the exact layout structure of the General component. - */ -function GeneralSkeleton() { - return ( -
- {/* User Info Section */} -
- -
-
- - -
- -
-
- - {/* Theme selector row */} -
- - -
- - {/* Auto-connect row */} -
- - -
- - {/* Error notifications row */} -
- - -
- - {/* Snap to grid row */} -
- - -
- - {/* Show canvas controls row */} -
- - -
- - {/* Telemetry row */} -
- - -
- - {/* Telemetry description */} -
- - -
- - {/* Action buttons */} -
- - - -
-
- ) -} - export function General() { const router = useRouter() const brandConfig = useBrandConfig() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/index.ts deleted file mode 100644 index 70a0761d62..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { ApiKeys } from './api-keys/api-keys' -export { BYOK } from './byok/byok' -export { Copilot } from './copilot/copilot' -export { CredentialSets } from './credential-sets/credential-sets' -export { Credentials } from './credentials/credentials' -export { CustomTools } from './custom-tools/custom-tools' -export { Debug } from './debug/debug' -export { General } from './general/general' -export { MCP } from './mcp/mcp' -export { Skills } from './skills/skills' -export { Subscription } from './subscription/subscription' -export { TeamManagement } from './team-management/team-management' -export { TemplateProfile } from './template-profile/template-profile' -export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton.tsx new file mode 100644 index 0000000000..45ffcdcfbd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton.tsx @@ -0,0 +1,14 @@ +import { McpServerSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-skeleton/mcp-server-skeleton' + +/** + * Skeleton for the MCP section shown during dynamic import loading. + */ +export function McpSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton.tsx new file mode 100644 index 0000000000..991ec10cd1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton.tsx @@ -0,0 +1,14 @@ +import { + CustomToolSkeleton, + CustomToolsSkeleton, +} from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton' + +/** + * Re-export CustomToolSkeleton as SkillSkeleton since both share identical markup. + */ +export const SkillSkeleton = CustomToolSkeleton + +/** + * Skeleton for the Skills section shown during dynamic import loading. + */ +export const SkillsSkeleton = CustomToolsSkeleton diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skills.tsx index 73d6fa78a7..e49481c9d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/skills.tsx @@ -5,28 +5,14 @@ import { createLogger } from '@sim/logger' import { Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' -import { Input, Skeleton } from '@/components/ui' +import { Input } from '@/components/ui' import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal' +import { SkillSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton' import type { SkillDefinition } from '@/hooks/queries/skills' import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' const logger = createLogger('SkillsSettings') -function SkillSkeleton() { - return ( -
-
- - -
-
- - -
-
- ) -} - export function Skills() { const params = useParams() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton.tsx new file mode 100644 index 0000000000..ec2b595a7b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton.tsx @@ -0,0 +1,6 @@ +import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton' + +/** + * Re-export McpSkeleton as WorkflowMcpServersSkeleton since both share identical markup. + */ +export const WorkflowMcpServersSkeleton = McpSkeleton diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 3ebd87522e..682bcf21ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -1,6 +1,7 @@ 'use client' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' +import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { ChevronDown, Skeleton } from '@/components/emcn' @@ -16,9 +17,10 @@ import { sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' import { useSSOProviders } from '@/ee/sso/hooks/sso' -import { useGeneralSettings } from '@/hooks/queries/general-settings' +import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials' +import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings' import { useOrganizations } from '@/hooks/queries/organization' -import { useSubscriptionData } from '@/hooks/queries/subscription' +import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription' import { useSuperUserStatus } from '@/hooks/queries/user-profile' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -28,10 +30,14 @@ export function SettingsSidebar() { const pathname = usePathname() const router = useRouter() + const queryClient = useQueryClient() const { data: session, isPending: sessionLoading } = useSession() const { data: organizationsData, isLoading: orgsLoading } = useOrganizations() const { data: generalSettings } = useGeneralSettings() - const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) + const { data: subscriptionData } = useSubscriptionData({ + enabled: isBillingEnabled, + staleTime: 5 * 60 * 1000, + }) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() const { data: superUserData } = useSuperUserStatus(session?.user?.id) @@ -129,6 +135,26 @@ export function SettingsSidebar() { return 'general' }, [pathname]) + const handlePrefetch = useCallback( + (itemId: string) => { + switch (itemId) { + case 'general': + prefetchGeneralSettings(queryClient) + void import('@/app/workspace/[workspaceId]/settings/components/general/general') + break + case 'credentials': + prefetchWorkspaceCredentials(queryClient, workspaceId) + void import('@/app/workspace/[workspaceId]/settings/components/credentials/credentials') + break + case 'subscription': + prefetchSubscriptionData(queryClient) + void import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription') + break + } + }, + [queryClient, workspaceId] + ) + const handleBack = () => { router.push(`/workspace/${workspaceId}/home`) } @@ -213,6 +239,8 @@ export function SettingsSidebar() { key={item.id} href={`/workspace/${workspaceId}/settings/${item.id}`} className={className} + onMouseEnter={() => handlePrefetch(item.id)} + onFocus={() => handlePrefetch(item.id)} > {content} diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 21680600d2..c668309abf 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -1,5 +1,6 @@ 'use client' +import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { environmentKeys } from '@/hooks/queries/environment' import { fetchJson } from '@/hooks/selectors/helpers' @@ -68,6 +69,33 @@ export const workspaceCredentialKeys = { [...workspaceCredentialKeys.detail(credentialId), 'members'] as const, } +/** + * Fetch workspace credential list from API. + * Used by the prefetch function for hover-based cache warming. + */ +async function fetchWorkspaceCredentialList( + workspaceId: string, + signal?: AbortSignal +): Promise { + const data = await fetchJson('/api/credentials', { + searchParams: { workspaceId }, + signal, + }) + return data.credentials ?? [] +} + +/** + * Prefetch workspace credentials into a QueryClient cache. + * Use on hover to warm data before navigation. + */ +export function prefetchWorkspaceCredentials(queryClient: QueryClient, workspaceId: string) { + queryClient.prefetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: 60 * 1000, + }) +} + export function useWorkspaceCredentials(params: { workspaceId?: string type?: WorkspaceCredentialType diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 16b715b5d1..d4015ddf43 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { syncThemeToNextThemes } from '@/lib/core/utils/theme' @@ -68,6 +69,18 @@ export function useGeneralSettings() { }) } +/** + * Prefetch general settings into a QueryClient cache. + * Use on hover to warm data before navigation. + */ +export function prefetchGeneralSettings(queryClient: QueryClient) { + queryClient.prefetchQuery({ + queryKey: generalSettingsKeys.settings(), + queryFn: () => fetchGeneralSettings(), + staleTime: 60 * 60 * 1000, + }) +} + /** * Convenience selector hooks for individual settings. * These provide a simple API for components that only need a single setting value. diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 6fe35d59d3..ea1bb1675d 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { organizationKeys } from '@/hooks/queries/organization' @@ -114,6 +115,8 @@ interface UseSubscriptionDataOptions { includeOrg?: boolean /** Whether to enable the query (defaults to true) */ enabled?: boolean + /** Override default staleTime (defaults to 30s) */ + staleTime?: number } /** @@ -121,17 +124,29 @@ interface UseSubscriptionDataOptions { * @param options - Optional configuration */ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { - const { includeOrg = false, enabled = true } = options + const { includeOrg = false, enabled = true, staleTime = 30 * 1000 } = options return useQuery({ queryKey: subscriptionKeys.user(includeOrg), queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal), - staleTime: 30 * 1000, + staleTime, placeholderData: keepPreviousData, enabled, }) } +/** + * Prefetch subscription data into a QueryClient cache. + * Use on hover to warm data before navigation. + */ +export function prefetchSubscriptionData(queryClient: QueryClient) { + queryClient.prefetchQuery({ + queryKey: subscriptionKeys.user(false), + queryFn: () => fetchSubscriptionData(false), + staleTime: 30 * 1000, + }) +} + /** * Fetch user usage limit metadata * Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.) From ab61f5188c85b6f155a1ed7e9e9cfcdf22ecd50a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:09:19 -0700 Subject: [PATCH 2/8] fix(settings): use emcn Skeleton in extracted skeleton files --- .../settings/components/api-keys/api-key-skeleton.tsx | 2 +- .../[workspaceId]/settings/components/byok/byok-skeleton.tsx | 2 +- .../components/credential-sets/credential-sets-skeleton.tsx | 2 +- .../settings/components/credentials/credential-skeleton.tsx | 2 +- .../settings/components/custom-tools/custom-tool-skeleton.tsx | 2 +- .../[workspaceId]/settings/components/debug/debug-skeleton.tsx | 2 +- .../settings/components/general/general-skeleton.tsx | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx index 5c2008c381..44c82deae9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton component for API key list items. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx index 53d0009fa8..f7e4ad85be 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton component for BYOK provider key items. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx index fed88f080d..4836eb6653 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton for the Credential Sets (Email Polling) section shown during dynamic import loading. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx index 6312b570a4..9041e4eab6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton component for credential list items. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx index 0a42850887..94bea6af4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton component for custom tool list items. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx index 9a59ad7b1f..0f7106093c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton for the Debug section shown during dynamic import loading. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx index 2aa5b22ad9..037ada7b28 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general-skeleton.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from '@/components/ui' +import { Skeleton } from '@/components/emcn' /** * Skeleton component for general settings loading state. From 63927e5afcdbecf6bc2f40152b44ef345b50fb59 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:21:43 -0700 Subject: [PATCH 3/8] fix(settings): extract shared response mappers to prevent server/client shape drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback — prefetch.ts duplicated response mapping logic from client hooks. Extracted mapGeneralSettingsResponse and mapUserProfileResponse as shared functions used by both client fetch and server prefetch. --- .../settings/[section]/prefetch.ts | 25 +++------------ apps/sim/hooks/queries/general-settings.ts | 31 ++++++++++++------- apps/sim/hooks/queries/user-profile.ts | 25 +++++++++------ 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index b39acf3bf4..7ac50861e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -1,8 +1,8 @@ import type { QueryClient } from '@tanstack/react-query' import { headers } from 'next/headers' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { generalSettingsKeys } from '@/hooks/queries/general-settings' -import { userProfileKeys } from '@/hooks/queries/user-profile' +import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings' +import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile' /** * Forwards incoming request cookies so server-side API fetches authenticate correctly. @@ -29,17 +29,7 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { }) if (!response.ok) throw new Error(`Settings prefetch failed: ${response.status}`) const { data } = await response.json() - return { - autoConnect: data.autoConnect ?? true, - showTrainingControls: data.showTrainingControls ?? false, - superUserModeEnabled: data.superUserModeEnabled ?? true, - theme: data.theme || 'system', - telemetryEnabled: data.telemetryEnabled ?? true, - billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true, - errorNotificationsEnabled: data.errorNotificationsEnabled ?? true, - snapToGridSize: data.snapToGridSize ?? 0, - showActionBar: data.showActionBar ?? true, - } + return mapGeneralSettingsResponse(data) }, staleTime: 60 * 60 * 1000, }) @@ -61,14 +51,7 @@ export function prefetchUserProfile(queryClient: QueryClient) { }) if (!response.ok) throw new Error(`Profile prefetch failed: ${response.status}`) const { user } = await response.json() - return { - id: user.id, - name: user.name || '', - email: user.email || '', - image: user.image || null, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - } + return mapUserProfileResponse(user) }, staleTime: 5 * 60 * 1000, }) diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index d4015ddf43..db1cd864f3 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -28,6 +28,24 @@ export interface GeneralSettings { showActionBar: boolean } +/** + * Map raw API response data to GeneralSettings with defaults. + * Shared by both client fetch and server prefetch to prevent shape drift. + */ +export function mapGeneralSettingsResponse(data: Record): GeneralSettings { + return { + autoConnect: (data.autoConnect as boolean) ?? true, + showTrainingControls: (data.showTrainingControls as boolean) ?? false, + superUserModeEnabled: (data.superUserModeEnabled as boolean) ?? true, + theme: (data.theme as GeneralSettings['theme']) || 'system', + telemetryEnabled: (data.telemetryEnabled as boolean) ?? true, + billingUsageNotificationsEnabled: (data.billingUsageNotificationsEnabled as boolean) ?? true, + errorNotificationsEnabled: (data.errorNotificationsEnabled as boolean) ?? true, + snapToGridSize: (data.snapToGridSize as number) ?? 0, + showActionBar: (data.showActionBar as boolean) ?? true, + } +} + /** * Fetch general settings from API */ @@ -39,18 +57,7 @@ async function fetchGeneralSettings(signal?: AbortSignal): Promise): UserProfile { + return { + id: user.id as string, + name: (user.name as string) || '', + email: (user.email as string) || '', + image: (user.image as string) || null, + createdAt: user.createdAt as string, + updatedAt: user.updatedAt as string, + } +} + /** * Fetch user profile from API */ @@ -35,15 +50,7 @@ async function fetchUserProfile(signal?: AbortSignal): Promise { } const { user } = await response.json() - - return { - id: user.id, - name: user.name || '', - email: user.email || '', - image: user.image || null, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - } + return mapUserProfileResponse(user) } /** From 80f032b9befba05dff2308694302de06659ded97 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:22:45 -0700 Subject: [PATCH 4/8] update byok page --- .../settings/components/byok/byok.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index b5df541858..d3d52b81d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -1,8 +1,8 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, @@ -30,6 +30,7 @@ import { PerplexityIcon, SerperIcon, } from '@/components/icons' +import { Input } from '@/components/ui' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { type BYOKKey, @@ -164,6 +165,7 @@ export function BYOK() { const upsertKey = useUpsertBYOKKey() const deleteKey = useDeleteBYOKKey() + const [searchTerm, setSearchTerm] = useState('') const [editingProvider, setEditingProvider] = useState(null) const [apiKeyInput, setApiKeyInput] = useState('') const [showApiKey, setShowApiKey] = useState(false) @@ -171,6 +173,18 @@ export function BYOK() { const [deleteConfirmProvider, setDeleteConfirmProvider] = useState(null) + const filteredProviders = useMemo(() => { + if (!searchTerm.trim()) return PROVIDERS + const searchLower = searchTerm.toLowerCase() + return PROVIDERS.filter( + (p) => + p.name.toLowerCase().includes(searchLower) || + p.description.toLowerCase().includes(searchLower) + ) + }, [searchTerm]) + + const showNoResults = searchTerm.trim() && filteredProviders.length === 0 + const getKeyForProvider = (providerId: BYOKProviderId): BYOKKey | undefined => { return keys.find((k) => k.providerId === providerId) } @@ -219,6 +233,22 @@ export function BYOK() { return ( <>
+
+
+ + setSearchTerm(e.target.value)} + disabled={isLoading} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+
+

Use your own API keys for hosted model providers.

@@ -232,7 +262,7 @@ export function BYOK() {
) : (
- {PROVIDERS.map((provider) => { + {filteredProviders.map((provider) => { const existingKey = getKeyForProvider(provider.id) const Icon = provider.icon @@ -274,6 +304,11 @@ export function BYOK() {
) })} + {showNoResults && ( +
+ No providers found matching "{searchTerm}" +
+ )} )} From 4ccc1e59978deb518e00be7f8d8574cdc0a861b5 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:29:06 -0700 Subject: [PATCH 5/8] fix(settings): include theme sync in client-side prefetch queryFn Hover-based prefetchGeneralSettings now calls syncThemeToNextThemes, matching the useGeneralSettings hook behavior so theme updates aren't missed when prefetch refreshes stale cache. --- apps/sim/hooks/queries/general-settings.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index db1cd864f3..cce59291b2 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -83,7 +83,11 @@ export function useGeneralSettings() { export function prefetchGeneralSettings(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), - queryFn: () => fetchGeneralSettings(), + queryFn: async () => { + const settings = await fetchGeneralSettings() + syncThemeToNextThemes(settings.theme) + return settings + }, staleTime: 60 * 60 * 1000, }) } From 5a5bf5ca7e9277554041da9d4963074ceace3b86 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:29:45 -0700 Subject: [PATCH 6/8] fix(byok): use EMCN Input for search field instead of ui Input Replace @/components/ui Input with the already-imported EmcnInput for design-system consistency. --- .../workspace/[workspaceId]/settings/components/byok/byok.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index d3d52b81d8..da7ab953d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -30,7 +30,6 @@ import { PerplexityIcon, SerperIcon, } from '@/components/icons' -import { Input } from '@/components/ui' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { type BYOKKey, @@ -239,7 +238,7 @@ export function BYOK() { className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' strokeWidth={2} /> - setSearchTerm(e.target.value)} From 37a1d6612772736a74bfcdd0146371218897d9f9 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:33:03 -0700 Subject: [PATCH 7/8] fix(byok): use ui Input for search bar to match other settings pages --- .../workspace/[workspaceId]/settings/components/byok/byok.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index da7ab953d2..d3d52b81d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -30,6 +30,7 @@ import { PerplexityIcon, SerperIcon, } from '@/components/icons' +import { Input } from '@/components/ui' import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { type BYOKKey, @@ -238,7 +239,7 @@ export function BYOK() { className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' strokeWidth={2} /> - setSearchTerm(e.target.value)} From 1aa9dc9ea74b6e50cb4d949f5e36e91719da89eb Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 9 Mar 2026 20:34:52 -0700 Subject: [PATCH 8/8] fix(settings): use emcn Input for file input in general settings --- .../[workspaceId]/settings/components/general/general.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index ba5fba54ab..1c65803cae 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation' import { Button, Combobox, + Input, Label, Modal, ModalBody, @@ -16,7 +17,6 @@ import { ModalHeader, Switch, } from '@/components/emcn' -import { Input } from '@/components/ui' import { signOut, useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { getEnv, isTruthy } from '@/lib/core/config/env'