From cdcbc436ac668e56d30c436af26abd5a15bf6972 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 17:19:04 -0700 Subject: [PATCH 1/5] improvement(settings): paint settings section clicks immediately and cut a round trip - Share the in-flight section navigation between the sidebar and the content area: the clicked section's heading paints over an empty body while its route resolves, with the outgoing section kept laid out but invisible and inert. It is optimistic state inside the navigation's own transition, not a Suspense fallback, so nothing is held back and a redirect back restores the section untouched. - A press warms data and code but no longer prefetches the route: a prefetch started at mousedown made the navigation wait on a second, two-step request instead of its own. - Warm every section's chunk on intent from the persistent settings layout instead of the sidebar, so the chunk loads alongside the route payload instead of after it, without growing the workspace chrome's module graph. - Seed fork availability for admins and read the server-seeded viewer permission, so Workspace Forks renders with the rest of the sidebar instead of after a session fetch and an availability fetch. --- apps/sim/app/o/[organizationId]/layout.tsx | 15 +- .../settings/[section]/layout.tsx | 17 +-- .../o/[organizationId]/settings/layout.tsx | 10 +- .../o/[organizationId]/settings/navigation.ts | 15 ++ .../workspace/[workspaceId]/layout.test.tsx | 4 + .../app/workspace/[workspaceId]/layout.tsx | 28 ++-- .../app/workspace/[workspaceId]/prefetch.ts | 30 ++++ .../settings/[section]/layout.tsx | 6 +- .../settings/[section]/settings.tsx | 127 +++++------------ .../[workspaceId]/settings/layout.tsx | 44 +++++- .../[workspaceId]/settings/section-warmers.ts | 114 +++++++++++++++ .../settings-query-warmers.ts | 70 ---------- .../settings-sidebar/settings-sidebar.tsx | 52 ++----- .../settings/settings-intent-link.tsx | 22 ++- .../settings/settings-navigation-provider.tsx | 131 ++++++++++++++++++ .../settings/standalone-settings-shell.tsx | 69 +++++---- .../use-pending-settings-selection.ts | 36 ++--- 17 files changed, 489 insertions(+), 301 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts create mode 100644 apps/sim/components/settings/settings-navigation-provider.tsx diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 82e18bbdacb..5658ec0818e 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -1,6 +1,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' +import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' @@ -81,12 +82,14 @@ export default async function OrganizationLayout({
- } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + +
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx index 4e0889175a7..a75b393eee1 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/layout.tsx @@ -1,13 +1,8 @@ import type { ReactNode } from 'react' import { notFound, redirect } from 'next/navigation' -import { - getSettingsSectionMeta, - ORGANIZATION_SETTINGS_ITEMS, - toSettingsHeaderMeta, -} from '@/components/settings/navigation' import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { organizationRoutes } from '@/lib/navigation/paths' -import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation' +import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation' interface OrganizationSettingsSectionLayoutProps { children: ReactNode @@ -24,18 +19,12 @@ export default async function OrganizationSettingsSectionLayout({ `${organizationRoutes(organizationId).settingsSection('general')}?view=authorized-apps` ) } - const resolved = resolveOrganizationSurfaceSection(section) - const meta = - resolved?.plane === 'organization' - ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section) - : resolved - ? getSettingsSectionMeta('account', resolved.section) - : null + const meta = resolveOrganizationSurfaceHeaderMeta(section) if (!meta) notFound() return ( - {children} + {children} ) } diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx index 52759b2ceb0..08823b651ee 100644 --- a/apps/sim/app/o/[organizationId]/settings/layout.tsx +++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx @@ -1,7 +1,9 @@ 'use client' import type { ReactNode } from 'react' +import { SettingsPendingSection } from '@/components/settings/settings-navigation-provider' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' +import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation' interface OrganizationSettingsLayoutProps { children: ReactNode @@ -9,5 +11,11 @@ interface OrganizationSettingsLayoutProps { export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) { useSettingsBeforeUnload() - return
{children}
+ return ( +
+ + {children} + +
+ ) } diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts index f559e4ae62e..ce3aac17ac5 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts @@ -1,6 +1,7 @@ import { ACCOUNT_SETTINGS_ITEMS, type AccountSettingsSection, + getSettingsSectionMeta, isOrganizationSettingsSectionAvailable, ORGANIZATION_SETTINGS_GROUPS, ORGANIZATION_SETTINGS_ITEMS, @@ -9,7 +10,9 @@ import { parseSettingsPathSection, resolveOrganizationSectionAccess, type SettingsNavigationItem, + toSettingsHeaderMeta, } from '@/components/settings/navigation' +import type { SettingsHeaderMeta } from '@/components/settings/settings-header' /** * A section on the organization surface's settings, tagged with the plane that @@ -64,6 +67,18 @@ export function resolveOrganizationSurfaceSection( return account ? { plane: 'account', section: account } : null } +/** The heading an organization-surface section path renders with, or null for an unknown path. */ +export function resolveOrganizationSurfaceHeaderMeta(path: string): SettingsHeaderMeta | null { + const resolved = resolveOrganizationSurfaceSection(path) + const item = + resolved?.plane === 'organization' + ? ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === resolved.section) + : resolved + ? getSettingsSectionMeta('account', resolved.section) + : null + return item ? toSettingsHeaderMeta(item) : null +} + export function organizationSettingsNavigation( isAdmin: boolean, features: OrganizationSettingsFeatures, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 64ba769d9d1..9a884ac45b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -13,12 +13,14 @@ const { mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, mockPrefetchWorkspaceAccess, + mockPrefetchWorkspaceForkAvailability, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), mockPrefetchWorkspaceAccess: vi.fn(), + mockPrefetchWorkspaceForkAvailability: vi.fn(), })) vi.mock('@sim/emcn', () => emcnMock) @@ -38,6 +40,7 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ vi.mock('@/app/workspace/[workspaceId]/prefetch', () => ({ prefetchWorkspaceHostContext: mockPrefetchWorkspaceHostContext, prefetchWorkspaceSidebar: mockPrefetchWorkspaceSidebar, + prefetchWorkspaceForkAvailability: mockPrefetchWorkspaceForkAvailability, })) vi.mock('@/app/workspace/[workspaceId]/prefetch-access', () => ({ @@ -149,6 +152,7 @@ describe('WorkspaceLayout host context', () => { mockPrefetchWorkspaceHostContext.mockResolvedValue(HOST_CONTEXT) mockPrefetchWorkspaceSidebar.mockResolvedValue(undefined) mockPrefetchWorkspaceAccess.mockResolvedValue(undefined) + mockPrefetchWorkspaceForkAvailability.mockResolvedValue(undefined) mockGetOrgWhitelabelSettings.mockResolvedValue({ brandName: 'Host B' }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index d875a37ba6a..79019d63b37 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,6 +1,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' +import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' @@ -12,6 +13,7 @@ import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { + prefetchWorkspaceForkAvailability, prefetchWorkspaceHostContext, prefetchWorkspaceSidebar, } from '@/app/workspace/[workspaceId]/prefetch' @@ -50,6 +52,11 @@ export default async function WorkspaceLayout({ } const activeOrganizationId = getActiveOrganizationId(session) + const principal = { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + } as const const [ cookieStore, initialOrgSettings, @@ -74,11 +81,8 @@ export default async function WorkspaceLayout({ isMothershipModelSelectorEnabled(), isPlanModeEnabled(), resolveOrganizationEntryPath(session), - prefetchWorkspaceAccess(queryClient, workspaceId, { - kind: 'session', - userId: session.user.id, - sessionId: session.session.id, - }), + prefetchWorkspaceAccess(queryClient, workspaceId, principal), + prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -108,12 +112,14 @@ export default async function WorkspaceLayout({ - } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + + diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 3ca4f3ed1f4..d69738630c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,3 +1,4 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import type { QueryClient } from '@tanstack/react-query' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { isChatEnabled } from '@/lib/core/config/env-flags' @@ -8,6 +9,10 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' +import { + FORK_AVAILABILITY_STALE_TIME, + forkAvailabilityKeys, +} from '@/ee/workspace-forking/hooks/use-forking-available' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, @@ -114,3 +119,28 @@ export async function prefetchWorkspaceSidebar( seedWorkspaceList(queryClient, userId, activeOrganizationId), ]) } + +/** + * Seeds fork availability, which decides whether the settings sidebar lists Workspace Forks, so + * the row renders with the rest of the sidebar. Only admins can read it + * (`forkOperations.discover`), so it is skipped for everyone else. It runs the availability + * route's own use case; a failed read stays out of hydration and the client refetches it. + */ +export async function prefetchWorkspaceForkAvailability( + queryClient: QueryClient, + workspaceId: string, + principal: SessionPrincipal, + hostContext: WorkspaceHostContext +): Promise { + if (hostContext.viewer.permission !== 'admin') return + await queryClient.prefetchQuery({ + queryKey: forkAvailabilityKeys.detail(workspaceId), + queryFn: async () => { + const { getWorkspaceForkAvailability } = await import( + '@/ee/workspace-forking/application/discovery' + ) + return getWorkspaceForkAvailability.execute({ principal, input: { workspaceId } }) + }, + staleTime: FORK_AVAILABILITY_STALE_TIME, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index de9cd125c5d..9d2be234029 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -34,9 +34,9 @@ const TOP_LEVEL_REDIRECTS: Readonly stri * boundary of its own. React holds content that resolves inside a freshly committed fallback for * at least 300ms, so any boundary mounted with the section — a route fallback, or a page-level * `` around the code-split body — put that floor under every section switch. Without - * one, a switch is a transition that keeps the outgoing section on screen until the incoming one - * and its chunk are ready (immediately, when the sidebar's intent prefetch has landed), and the - * sidebar moves its selection on click so the click still reads as acknowledged. A boundary above + * one, a switch is a transition: the sidebar moves its selection and the settings layout paints + * the incoming heading over the outgoing section, which stays mounted but invisible until the + * incoming section and its chunk are ready. A boundary above * this layout (a `settings/loading.tsx`, or a `` in the settings layout) would also * swallow the 404 and 307 above. * diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index fa715bb3687..a6354b4d3fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -14,121 +14,56 @@ import { getSettingsSectionMeta, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { SECTION_MODULES } from '@/app/workspace/[workspaceId]/settings/section-warmers' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' -const Admin = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) -) -const ApiKeys = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys').then( - (m) => m.ApiKeys - ) -) -const BYOK = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) -) -const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) -const Secrets = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) -) +const Admin = dynamic(() => SECTION_MODULES.admin().then((m) => m.Admin)) +const ApiKeys = dynamic(() => SECTION_MODULES.apikeys().then((m) => m.ApiKeys)) +const BYOK = dynamic(() => SECTION_MODULES.byok().then((m) => m.BYOK)) +const Forks = dynamic(() => SECTION_MODULES.forks().then((m) => m.Forks)) +const Secrets = dynamic(() => SECTION_MODULES.secrets().then((m) => m.Secrets)) const OrganizationConnectedAccounts = dynamic(() => - import('@/ee/credential-groups/components/organization-connected-accounts').then( - (m) => m.OrganizationConnectedAccounts - ) -) -const Sandboxes = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( - (m) => m.Sandboxes - ) -) -const CustomTools = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( - (m) => m.CustomTools - ) -) -const Inbox = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox) -) -const MCP = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp').then((m) => m.MCP) -) -const Mothership = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then( - (m) => m.Mothership - ) + SECTION_MODULES['connected-accounts']().then((m) => m.OrganizationConnectedAccounts) ) +const Sandboxes = dynamic(() => SECTION_MODULES.sandboxes().then((m) => m.Sandboxes)) +const CustomTools = dynamic(() => SECTION_MODULES['custom-tools']().then((m) => m.CustomTools)) +const Inbox = dynamic(() => SECTION_MODULES.inbox().then((m) => m.Inbox)) +const MCP = dynamic(() => SECTION_MODULES.mcp().then((m) => m.MCP)) +const Mothership = dynamic(() => SECTION_MODULES.mothership().then((m) => m.Mothership)) const RecentlyDeleted = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' - ).then((m) => m.RecentlyDeleted) -) -const SelfHost = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host').then( - (m) => m.SelfHost - ) -) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing) -) -const Teammates = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then( - (m) => m.Teammates - ) -) -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (m) => m.TeamManagement - ) + SECTION_MODULES['recently-deleted']().then((m) => m.RecentlyDeleted) ) +const SelfHost = dynamic(() => SECTION_MODULES['self-host']().then((m) => m.SelfHost)) +const Billing = dynamic(() => SECTION_MODULES.billing().then((m) => m.Billing)) +const Teammates = dynamic(() => SECTION_MODULES.teammates().then((m) => m.Teammates)) +const TeamManagement = dynamic(() => SECTION_MODULES.organization().then((m) => m.TeamManagement)) const WorkflowMcpServers = dynamic(() => - import( - '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' - ).then((m) => m.WorkflowMcpServers) + SECTION_MODULES['workflow-mcp-servers']().then((m) => m.WorkflowMcpServers) ) const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) + SECTION_MODULES['access-control']().then((m) => m.AccessControl) ) const AccessRequestsSettings = dynamic(() => - import('@/ee/access-requests/components/access-requests-settings').then( - (m) => m.AccessRequestsSettings - ) -) -const CustomBlocks = dynamic(() => - import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) -) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) + SECTION_MODULES.requests().then((m) => m.AccessRequestsSettings) ) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const CustomBlocks = dynamic(() => SECTION_MODULES['custom-blocks']().then((m) => m.CustomBlocks)) +const AuditLogs = dynamic(() => SECTION_MODULES['audit-logs']().then((m) => m.AuditLogs)) +const SSO = dynamic(() => SECTION_MODULES.sso().then((m) => m.SSO)) const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (m) => m.DataRetentionSettings - ) + SECTION_MODULES['data-retention']().then((m) => m.DataRetentionSettings) ) const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings) + SECTION_MODULES['data-drains']().then((m) => m.DataDrainsSettings) ) const OrganizationSecuritySettings = dynamic(() => - import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings) -) -const UsageMonitoring = dynamic(() => - import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring) -) -const Desktop = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop) -) -const Browser = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/browser/browser').then((m) => m.Browser) -) -const Terminal = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal').then( - (m) => m.Terminal - ) + SECTION_MODULES.security().then((m) => m.OrganizationSecuritySettings) ) +const UsageMonitoring = dynamic(() => SECTION_MODULES.usage().then((m) => m.UsageMonitoring)) +const Desktop = dynamic(() => SECTION_MODULES.desktop().then((m) => m.Desktop)) +const Browser = dynamic(() => SECTION_MODULES.browser().then((m) => m.Browser)) +const Terminal = dynamic(() => SECTION_MODULES.terminal().then((m) => m.Terminal)) const WhitelabelingSettings = dynamic(() => - import('@/ee/whitelabeling/components/whitelabeling-settings').then( - (m) => m.WhitelabelingSettings - ) + SECTION_MODULES.whitelabeling().then((m) => m.WhitelabelingSettings) ) interface SettingsPageProps { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx index f3b58312271..26f6205a4f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx @@ -1,8 +1,50 @@ 'use client' +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { + SettingsPendingSection, + useSettingsIntentHandler, +} from '@/components/settings/settings-navigation-provider' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { + resolveSettingsSection, + type SettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' +import { warmSettingsSection } from '@/app/workspace/[workspaceId]/settings/section-warmers' +function pendingSectionMeta(section: string) { + return resolveSettingsSection(section)?.meta ?? null +} + +/** + * Persists across every settings route — sections and detail pages alike — so it owns what the + * sidebar needs while any of them is open: the pending-section preview and the warmer that + * sidebar navigation intent runs. + */ export default function SettingsLayout({ children }: { children: React.ReactNode }) { useSettingsBeforeUnload() - return
{children}
+ const queryClient = useQueryClient() + const hostContext = useWorkspaceHostContext() + const workspaceId = hostContext.workspace.id + const billingOrganizationId = hostContext.hostOrganizationId + + useSettingsIntentHandler( + useCallback( + (section: string) => + warmSettingsSection( + queryClient, + { workspaceId, billingOrganizationId }, + section as SettingsSection + ), + [queryClient, workspaceId, billingOrganizationId] + ) + ) + + return ( +
+ {children} +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts b/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts new file mode 100644 index 00000000000..7e8bf2571b0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts @@ -0,0 +1,114 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { customToolsQueryOptions } from '@/hooks/queries/custom-tools' +import { + personalEnvironmentQueryOptions, + workspaceEnvironmentQueryOptions, +} from '@/hooks/queries/environment' +import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations' +import { mcpServersQueryOptions } from '@/hooks/queries/mcp' +import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' +import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' +import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' + +/** + * Each code-split section's module, shared by its `dynamic()` body and the navigation-intent + * warmer so a section's chunk can start loading on hover or press rather than after its route. + */ +export const SECTION_MODULES = { + admin: () => import('@/app/workspace/[workspaceId]/settings/components/admin/admin'), + apikeys: () => import('@/app/workspace/[workspaceId]/settings/components/api-keys/api-keys'), + byok: () => import('@/app/workspace/[workspaceId]/settings/components/byok/byok'), + forks: () => import('@/ee/workspace-forking/components/forks'), + secrets: () => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets'), + 'connected-accounts': () => + import('@/ee/credential-groups/components/organization-connected-accounts'), + sandboxes: () => import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes'), + 'custom-tools': () => + import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools'), + inbox: () => import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox'), + mcp: () => import('@/app/workspace/[workspaceId]/settings/components/mcp/mcp'), + mothership: () => + import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership'), + 'recently-deleted': () => + import('@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'), + 'self-host': () => + import('@/app/workspace/[workspaceId]/settings/components/self-host/self-host'), + billing: () => import('@/app/workspace/[workspaceId]/settings/components/billing/billing'), + teammates: () => import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates'), + organization: () => + import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management'), + 'workflow-mcp-servers': () => + import( + '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' + ), + 'access-control': () => import('@/ee/access-control/components/access-control'), + requests: () => import('@/ee/access-requests/components/access-requests-settings'), + 'custom-blocks': () => import('@/ee/custom-blocks/components/custom-blocks'), + 'audit-logs': () => import('@/ee/audit-logs/components/audit-logs'), + sso: () => import('@/ee/sso/components/sso-settings'), + 'data-retention': () => import('@/ee/data-retention/components/data-retention-settings'), + 'data-drains': () => import('@/ee/data-drains/components/data-drains-settings'), + security: () => import('@/components/settings/organization-security'), + usage: () => import('@/ee/organization-usage/components/usage-monitoring'), + desktop: () => import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop'), + browser: () => import('@/app/workspace/[workspaceId]/settings/components/browser/browser'), + terminal: () => import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal'), + whitelabeling: () => import('@/ee/whitelabeling/components/whitelabeling-settings'), +} satisfies Partial Promise>> + +/** Each section's first-content queries. */ +const SETTINGS_QUERY_WARMERS: Partial< + Record void> +> = { + secrets: (queryClient, { workspaceId }) => { + prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions()) + prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId)) + prefetchQueryOnIntent( + queryClient, + workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') + ) + }, + forks: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)), + teammates: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)), + apikeys: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), + 'custom-tools': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)), + mcp: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), + 'workflow-mcp-servers': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), + billing: (queryClient, { billingOrganizationId }) => { + if (billingOrganizationId) { + prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) + return + } + prefetchQueryOnIntent(queryClient, subscriptionDataQueryOptions(false)) + }, +} + +export interface SettingsQueryWarmContext { + workspaceId: string + billingOrganizationId: string | null +} + +/** + * Warms a section on sidebar navigation intent: its code-split chunk and its first-content + * queries start alongside the route payload instead of after the section mounts. + */ +export function warmSettingsSection( + queryClient: QueryClient, + context: SettingsQueryWarmContext, + section: SettingsSection +): void { + // A failed warm is not an error: the section's own load reports it if the navigation happens. + SECTION_MODULES[section as keyof typeof SECTION_MODULES]?.().catch(() => {}) + SETTINGS_QUERY_WARMERS[section]?.(queryClient, context) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts deleted file mode 100644 index 90d22fc97cc..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { QueryClient } from '@tanstack/react-query' -import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { forkLineageQueryOptions } from '@/ee/workspace-forking/hooks/workspace-fork' -import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' -import { customToolsQueryOptions } from '@/hooks/queries/custom-tools' -import { - personalEnvironmentQueryOptions, - workspaceEnvironmentQueryOptions, -} from '@/hooks/queries/environment' -import { pendingInvitationsQueryOptions } from '@/hooks/queries/invitations' -import { mcpServersQueryOptions } from '@/hooks/queries/mcp' -import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' -import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' -import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' -import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' -import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' - -/** - * Each section's first-content queries, started on navigation intent so they load alongside the - * route payload instead of after the section mounts. - */ -const SETTINGS_QUERY_WARMERS: Partial< - Record void> -> = { - secrets: (queryClient, { workspaceId }) => { - prefetchQueryOnIntent(queryClient, personalEnvironmentQueryOptions()) - prefetchQueryOnIntent(queryClient, workspaceEnvironmentQueryOptions(workspaceId)) - prefetchQueryOnIntent( - queryClient, - workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') - ) - }, - forks: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, forkLineageQueryOptions(workspaceId)), - teammates: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, pendingInvitationsQueryOptions(workspaceId)), - apikeys: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), - 'custom-tools': (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, customToolsQueryOptions(workspaceId)), - mcp: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), - 'workflow-mcp-servers': (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), - billing: (queryClient, { billingOrganizationId }) => { - if (billingOrganizationId) { - prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) - return - } - prefetchQueryOnIntent(queryClient, subscriptionDataQueryOptions(false)) - }, -} - -export interface SettingsQueryWarmContext { - workspaceId: string - billingOrganizationId: string | null -} - -/** Starts approved first-content data within the workspace graph's enforced module budget. */ -export function warmSettingsSectionQuery( - queryClient: QueryClient, - context: SettingsQueryWarmContext, - section: SettingsSection -): boolean { - const warmer = SETTINGS_QUERY_WARMERS[section] - if (!warmer) return false - - warmer(queryClient, context) - return true -} 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 3bf9559e163..bc0731add9f 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 @@ -14,7 +14,6 @@ import { useScrollEdges, } from '@sim/emcn' import { ArrowUpRight, Building, ChevronLeft, Lock } from '@sim/emcn/icons' -import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' import { type DesktopSettingsSurface, @@ -24,6 +23,7 @@ import { ORGANIZATION_PLANE_UNIFIED_SECTIONS, } from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' @@ -31,13 +31,12 @@ import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permiss import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { hasBrowserAgent, hasDesktopSettings, hasTerminal } from '@/lib/desktop' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { allNavigationItems, sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' -import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { @@ -56,31 +55,6 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' -/** - * Sections whose JS chunk is warmed when a row receives navigation intent. - * - * Deliberately not all of them, and the reason is the boundary audit rather than bundle weight. - * Each section is already `dynamic()`-imported by the settings panel, so naming it here adds an - * async-chunk reference, not parsed JS — but `check-tool-registry-boundary` counts `import()` - * as a graph edge on purpose, and listing all of them measured +126..+172 modules against six of - * the app's hottest route baselines. Code-splitting this sidebar does not help: measured, it - * moves exactly one module, because the audit follows the dynamic edge either way. - * - * These six predate this map and are already inside those baselines, so warming them is free. - * Widening it means either raising the ratchet on the routes it exists to protect, or teaching - * the audit to track async reach separately from initial-chunk weight. - * - * Every section still gets its route payload warmed through {@link SettingsIntentLink}. - */ -const SECTION_CHUNK_WARMERS: Partial Promise>> = { - general: () => import('@/app/workspace/[workspaceId]/settings/components/general/general'), - secrets: () => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets'), - billing: () => import('@/app/workspace/[workspaceId]/settings/components/billing/billing'), - desktop: () => import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop'), - browser: () => import('@/app/workspace/[workspaceId]/settings/components/browser/browser'), - terminal: () => import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal'), -} - interface SettingsSidebarProps { isCollapsed?: boolean showCollapsedTooltips?: boolean @@ -98,8 +72,6 @@ export function SettingsSidebar({ const pathname = usePathname() const router = useRouter() - const queryClient = useQueryClient() - const requestLeave = useSettingsDirtyStore((s) => s.requestLeave) const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave) const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave) @@ -130,7 +102,13 @@ export function SettingsSidebar({ const accessRequests = useWorkspaceAccessRequestFeatures() const accessRequestsEnabled = accessRequests.data?.enabled === true const forkingAvailable = useForkingAvailable(workspaceId) - const { canAdmin: canAdminWorkspace } = useUserPermissionsContext() + const { workspacePermissions, userPermissions } = useWorkspacePermissionsContext() + // The server-seeded viewer permission, read directly: the derived admin flag waits on the client + // session. Offline mode still withdraws admin; without the viewer field, fall back to the flag. + const viewerPermission = workspacePermissions?.viewer?.permissionType + const canAdminWorkspace = viewerPermission + ? viewerPermission === 'admin' && !userPermissions.isOfflineMode + : userPermissions.canAdmin const userId = session?.user?.id @@ -300,18 +278,10 @@ export function SettingsSidebar({ ? (segments[settingsIndex + 1] as SettingsSection) : 'general' const { activeSection, navigateToSection } = usePendingSettingsSelection(routeSection) + const { signalIntent } = useSettingsNavigationState() const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() - const handleIntent = (section: SettingsSection) => { - void SECTION_CHUNK_WARMERS[section]?.() - warmSettingsSectionQuery( - queryClient, - { workspaceId, billingOrganizationId: hostContext.hostOrganizationId }, - section - ) - } - const handleBack = useCallback(() => { requestLeave(() => { router.push(popSettingsReturnUrl(`/workspace/${workspaceId}`)) @@ -484,7 +454,7 @@ export function SettingsSidebar({ permissionRestricted ? `${item.label}: access required` : undefined } className={itemClassName} - onIntent={() => !permissionRestricted && handleIntent(section)} + onIntent={() => !permissionRestricted && signalIntent(section)} onNavigate={(event) => { if (active) { event.preventDefault() diff --git a/apps/sim/components/settings/settings-intent-link.tsx b/apps/sim/components/settings/settings-intent-link.tsx index e5568163a62..8931b9c4921 100644 --- a/apps/sim/components/settings/settings-intent-link.tsx +++ b/apps/sim/components/settings/settings-intent-link.tsx @@ -80,14 +80,26 @@ function IntentAwareSettingsLink({ prefetchTimerRef.current = null }, []) - const prefetchForIntent = () => { + /** Runs the caller's intent warmer; the route payload is left to the navigation. */ + const warmForIntent = () => { cancelScheduledPrefetch() if (isCurrentRoute || navigationIntentRef.current) return navigationIntentRef.current = true - setShouldPrefetchRoute(true) onIntent?.() } + /** + * Hover and focus land well ahead of a click, so they also prefetch the route payload. A press + * does not: the navigation follows within a frame or two, and a prefetch started then is a + * second request the navigation ends up waiting on (tree, then data) instead of its own one. + */ + const prefetchForIntent = () => { + cancelScheduledPrefetch() + if (isCurrentRoute) return + warmForIntent() + setShouldPrefetchRoute(true) + } + const schedulePrefetch = () => { cancelScheduledPrefetch() prefetchTimerRef.current = setTimeout(() => { @@ -127,13 +139,13 @@ function IntentAwareSettingsLink({ onPointerDown={(event) => { onPointerDown?.(event) if (event.pointerType === 'mouse' && isUnmodifiedPrimaryPointer(event)) { - prefetchForIntent() + warmForIntent() } }} onPointerUp={(event) => { onPointerUp?.(event) if (event.pointerType !== 'mouse' && isUnmodifiedPrimaryPointer(event)) { - prefetchForIntent() + warmForIntent() } }} onPointerCancel={(event) => { @@ -152,7 +164,7 @@ function IntentAwareSettingsLink({ !isCurrentRoute && !navigationIntentRef.current ) { - prefetchForIntent() + warmForIntent() } }} /> diff --git a/apps/sim/components/settings/settings-navigation-provider.tsx b/apps/sim/components/settings/settings-navigation-provider.tsx new file mode 100644 index 00000000000..963077f0de5 --- /dev/null +++ b/apps/sim/components/settings/settings-navigation-provider.tsx @@ -0,0 +1,131 @@ +'use client' + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useOptimistic, + useRef, + useTransition, +} from 'react' +import { useRouter } from 'next/navigation' +import { + type SettingsHeaderMeta, + SettingsHeaderProvider, + SettingsHeaderShell, +} from '@/components/settings/settings-header' + +interface SettingsNavigationState { + /** The section a sidebar click is navigating to, until that navigation settles. */ + pendingSection: string | null + /** Navigates to a section, previewing `pendingSection` until it settles; null previews nothing. */ + navigateToSection: (pendingSection: string | null, href: string) => void + /** Hands a sidebar row's navigation intent to the mounted settings layout's handler, if any. */ + signalIntent: (section: string) => void + registerIntentHandler: (handler: (section: string) => void) => () => void +} + +const SettingsNavigationContext = createContext(null) + +/** + * Shares an in-flight settings section navigation between the sidebar that starts it and the + * content area that previews it. + * + * Section routes have no loading boundary (see the workspace section layout), so without this the + * outgoing section stays on screen for the whole round trip. The pending section is optimistic + * state set inside the navigation's own transition: React drops it the moment that transition + * settles — on commit, and equally when the server redirects back or the navigation fails — and + * because it is state rather than a Suspense fallback, the incoming section is never held back. + */ +export function SettingsNavigationProvider({ children }: { children: ReactNode }) { + const router = useRouter() + const [pendingSection, setPendingSection] = useOptimistic(null) + const [, startTransition] = useTransition() + + const navigateToSection = useCallback( + (section: string | null, href: string) => { + startTransition(() => { + setPendingSection(section) + router.replace(href, { scroll: false }) + }) + }, + [router, setPendingSection] + ) + + const intentHandlerRef = useRef<((section: string) => void) | null>(null) + const signalIntent = useCallback((section: string) => intentHandlerRef.current?.(section), []) + const registerIntentHandler = useCallback((handler: (section: string) => void) => { + intentHandlerRef.current = handler + return () => { + if (intentHandlerRef.current === handler) intentHandlerRef.current = null + } + }, []) + + const value = useMemo( + () => ({ pendingSection, navigateToSection, signalIntent, registerIntentHandler }), + [pendingSection, navigateToSection, signalIntent, registerIntentHandler] + ) + + return ( + + {children} + + ) +} + +export function useSettingsNavigationState(): SettingsNavigationState { + const state = useContext(SettingsNavigationContext) + if (!state) { + throw new Error('useSettingsNavigationState must be used within a SettingsNavigationProvider') + } + return state +} + +/** + * Registers the mounted settings layout's warmer for sidebar navigation intent. The settings route + * owns it, not the sidebar, so warming every section's chunk and data adds nothing to the + * workspace chrome's module graph, which every workspace page loads. + */ +export function useSettingsIntentHandler(handler: (section: string) => void): void { + const { registerIntentHandler } = useSettingsNavigationState() + useEffect(() => registerIntentHandler(handler), [registerIntentHandler, handler]) +} + +interface SettingsPendingSectionProps { + /** The header a pending section paints with, or null to keep showing the current section. */ + resolveMeta: (section: string) => SettingsHeaderMeta | null + children: ReactNode +} + +/** + * Paints a clicked section's heading over an empty body while its route resolves — the same frame + * the route commits with, minus the body — so the click lands immediately. The current section + * stays mounted and laid out underneath, invisible and inert, so its scroll position and state are + * intact if the navigation settles back on it. With nothing pending both wrappers are + * `display: contents` and add no box of their own. + */ +export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) { + const { pendingSection } = useSettingsNavigationState() + const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null + + return ( +
+ {pendingMeta && ( +
+ + {null} + +
+ )} +
+ {children} +
+
+ ) +} diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 61b01132f72..febcd825799 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -9,12 +9,18 @@ import { ACCOUNT_SETTINGS_PATH_ALIASES, getAccountSettingsHref, getSelfHostSettingsHref, + getSettingsSectionMeta, parseSettingsPathSection, SELFHOST_SETTINGS_GROUPS, SELFHOST_SETTINGS_ITEMS, SETTINGS_PLANE_CHROME, + toSettingsHeaderMeta, } from '@/components/settings/navigation' import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { + SettingsNavigationProvider, + SettingsPendingSection, +} from '@/components/settings/settings-navigation-provider' import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' @@ -41,6 +47,11 @@ interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps { type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettingsShellProps +function pendingSectionMeta(plane: 'account' | 'selfhost', section: string) { + const item = getSettingsSectionMeta(plane, section) + return item ? toSettingsHeaderMeta(item) : null +} + export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSeedDeploymentShape(props.deployment) @@ -96,33 +107,37 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { ) return ( -
- {/* - Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless - sidebar column against the app surface, meeting the content pane on a - single hairline divider with no gutter. Keep the two in step — a settings - page should look the same whether it is reached inside a workspace or not. - */} - -
-
- - - - {children} - - - -
+ +
+ {/* + Mirrors the in-workspace chrome (WorkspaceChrome): a flush, borderless + sidebar column against the app surface, meeting the content pane on a + single hairline divider with no gutter. Keep the two in step — a settings + page should look the same whether it is reached inside a workspace or not. + */} + +
+
+ pendingSectionMeta(plane, section)}> + + + + {children} + + + + +
+
-
+ ) } diff --git a/apps/sim/components/settings/use-pending-settings-selection.ts b/apps/sim/components/settings/use-pending-settings-selection.ts index 2237ff02ef2..ae4c0744489 100644 --- a/apps/sim/components/settings/use-pending-settings-selection.ts +++ b/apps/sim/components/settings/use-pending-settings-selection.ts @@ -1,35 +1,19 @@ 'use client' -import { useCallback, useOptimistic, useTransition } from 'react' -import { useRouter } from 'next/navigation' +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' /** - * Settings sidebar navigation whose clicked row shows as selected until the route settles. - * - * Section routes have no loading boundary (see the workspace section layout), so a navigation - * keeps the outgoing section on screen until the incoming one is ready. Moving the selection on - * click keeps the click acknowledged while the route resolves. - * - * The selection is optimistic state set inside the navigation's own transition, so React drops it - * the moment that transition settles — on commit, and equally when the server redirects back to - * the current section or the navigation fails. It never outlives the navigation that set it. + * The sidebar's selected row: the section a click is navigating to until that navigation settles + * (see `SettingsNavigationProvider`), otherwise the section the route resolved to. */ export function usePendingSettingsSelection( routeSection: TSection ): { activeSection: TSection; navigateToSection: (section: TSection, href: string) => void } { - const router = useRouter() - const [activeSection, setOptimisticSection] = useOptimistic(routeSection) - const [, startTransition] = useTransition() - - const navigateToSection = useCallback( - (section: TSection, href: string) => { - startTransition(() => { - setOptimisticSection(section) - router.replace(href, { scroll: false }) - }) - }, - [router, setOptimisticSection] - ) - - return { activeSection, navigateToSection } + const { pendingSection, navigateToSection } = useSettingsNavigationState() + return { + activeSection: (pendingSection as TSection | null) ?? routeSection, + // Returning to the section already on screen previews nothing: its content is what will show. + navigateToSection: (section, href) => + navigateToSection(section === routeSection ? null : section, href), + } } From bfd1d502b8961abbc0b64bc6d35768f3675740ce Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 18:12:10 -0700 Subject: [PATCH 2/5] fix(settings): keep press prefetch, guard fork direction switch, tighten docs - Restore the sidebar link's press-time route prefetch. Measured with a realistic ~90ms press, the prefetch mousedown starts completes before the click commits, so removing it slowed every navigation (commit ~105ms -> ~190ms after mousedown). - Route the fork sync direction switch through the unsaved-changes guard: switching Push/Pull drops the in-session mapping choices, so unsaved edits confirm first. - Give SettingsNavigationProvider a props interface, and scope the section layout's 404/307 note to section authorization, which is what an unknown or legacy segment skips. --- .../settings/[section]/layout.tsx | 10 ++++----- .../settings/settings-intent-link.tsx | 22 +++++-------------- .../settings/settings-navigation-provider.tsx | 6 ++++- .../fork-sync-detail-view.tsx | 3 ++- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index 9d2be234029..e1499f22311 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -26,8 +26,8 @@ const TOP_LEVEL_REDIRECTS: Readonly stri * the shell instead of waiting on the body's lazily-loaded chunk. * * Whether a segment names a section at all is decided here too, before the shell renders, so a - * bad or legacy URL loaded directly answers 404 or 307 without touching the database. Whether - * the *viewer* may open a section is a different question and stays in the page, where it + * bad or legacy URL loaded directly answers 404 or 307 without running section authorization. + * Whether the *viewer* may open a section is a different question and stays in the page, where it * belongs; those checks need the database and are reached almost entirely by client navigation. * * There is deliberately no sibling `loading.tsx`, and the page renders its body under no Suspense @@ -36,9 +36,9 @@ const TOP_LEVEL_REDIRECTS: Readonly stri * `` around the code-split body — put that floor under every section switch. Without * one, a switch is a transition: the sidebar moves its selection and the settings layout paints * the incoming heading over the outgoing section, which stays mounted but invisible until the - * incoming section and its chunk are ready. A boundary above - * this layout (a `settings/loading.tsx`, or a `` in the settings layout) would also - * swallow the 404 and 307 above. + * incoming section and its chunk are ready. A boundary above this layout (a + * `settings/loading.tsx`, or a `` in the settings layout) would also swallow the 404 + * and 307 above. * * Authentication is already enforced by the ancestor workspace layout, so this runs only for a * signed-in viewer. diff --git a/apps/sim/components/settings/settings-intent-link.tsx b/apps/sim/components/settings/settings-intent-link.tsx index 8931b9c4921..e5568163a62 100644 --- a/apps/sim/components/settings/settings-intent-link.tsx +++ b/apps/sim/components/settings/settings-intent-link.tsx @@ -80,24 +80,12 @@ function IntentAwareSettingsLink({ prefetchTimerRef.current = null }, []) - /** Runs the caller's intent warmer; the route payload is left to the navigation. */ - const warmForIntent = () => { + const prefetchForIntent = () => { cancelScheduledPrefetch() if (isCurrentRoute || navigationIntentRef.current) return navigationIntentRef.current = true - onIntent?.() - } - - /** - * Hover and focus land well ahead of a click, so they also prefetch the route payload. A press - * does not: the navigation follows within a frame or two, and a prefetch started then is a - * second request the navigation ends up waiting on (tree, then data) instead of its own one. - */ - const prefetchForIntent = () => { - cancelScheduledPrefetch() - if (isCurrentRoute) return - warmForIntent() setShouldPrefetchRoute(true) + onIntent?.() } const schedulePrefetch = () => { @@ -139,13 +127,13 @@ function IntentAwareSettingsLink({ onPointerDown={(event) => { onPointerDown?.(event) if (event.pointerType === 'mouse' && isUnmodifiedPrimaryPointer(event)) { - warmForIntent() + prefetchForIntent() } }} onPointerUp={(event) => { onPointerUp?.(event) if (event.pointerType !== 'mouse' && isUnmodifiedPrimaryPointer(event)) { - warmForIntent() + prefetchForIntent() } }} onPointerCancel={(event) => { @@ -164,7 +152,7 @@ function IntentAwareSettingsLink({ !isCurrentRoute && !navigationIntentRef.current ) { - warmForIntent() + prefetchForIntent() } }} /> diff --git a/apps/sim/components/settings/settings-navigation-provider.tsx b/apps/sim/components/settings/settings-navigation-provider.tsx index 963077f0de5..084311e750a 100644 --- a/apps/sim/components/settings/settings-navigation-provider.tsx +++ b/apps/sim/components/settings/settings-navigation-provider.tsx @@ -30,6 +30,10 @@ interface SettingsNavigationState { const SettingsNavigationContext = createContext(null) +interface SettingsNavigationProviderProps { + children: ReactNode +} + /** * Shares an in-flight settings section navigation between the sidebar that starts it and the * content area that previews it. @@ -40,7 +44,7 @@ const SettingsNavigationContext = createContext( * settles — on commit, and equally when the server redirects back or the navigation fails — and * because it is state rather than a Suspense fallback, the incoming section is never held back. */ -export function SettingsNavigationProvider({ children }: { children: ReactNode }) { +export function SettingsNavigationProvider({ children }: SettingsNavigationProviderProps) { const router = useRouter() const [pendingSection, setPendingSection] = useOptimistic(null) const [, startTransition] = useTransition() diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx index 89f4acc6b7b..3835cd0d804 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx @@ -114,7 +114,8 @@ export function ForkSyncDetailView({ > void setDirection(next)} + // A direction switch drops the in-session mapping choices, so unsaved edits confirm first. + onDirectionChange={(next) => guard.guardBack(() => void setDirection(next))} /> From 64fd53c46f9968f685f1b17c048fc5ba0fa4f49f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 18:34:04 -0700 Subject: [PATCH 3/5] fix(settings): confirm every discarded fork choice, keep settings UI out of the workspace chrome - A fork sync direction switch drops every in-session choice, not just unsaved mapping edits. Expose hasSessionChoices (mapping edits, a copy selection that differs from the default, accepted dropped references, trigger URL choices) and confirm the switch whenever any is set. - Move SettingsPendingSection into its own module so the provider in the workspace chrome no longer imports the settings header UI. --- .../o/[organizationId]/settings/layout.tsx | 2 +- .../[workspaceId]/settings/layout.tsx | 6 +-- .../settings/settings-navigation-provider.tsx | 41 ----------------- .../settings/settings-pending-section.tsx | 45 +++++++++++++++++++ .../settings/standalone-settings-shell.tsx | 6 +-- .../fork-sync-detail-view.tsx | 24 +++++++--- .../components/fork-sync/use-fork-sync.ts | 21 +++++++++ 7 files changed, 90 insertions(+), 55 deletions(-) create mode 100644 apps/sim/components/settings/settings-pending-section.tsx diff --git a/apps/sim/app/o/[organizationId]/settings/layout.tsx b/apps/sim/app/o/[organizationId]/settings/layout.tsx index 08823b651ee..84e588433a7 100644 --- a/apps/sim/app/o/[organizationId]/settings/layout.tsx +++ b/apps/sim/app/o/[organizationId]/settings/layout.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import { SettingsPendingSection } from '@/components/settings/settings-navigation-provider' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx index 26f6205a4f3..ed92a72a23a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx @@ -2,10 +2,8 @@ import { useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { - SettingsPendingSection, - useSettingsIntentHandler, -} from '@/components/settings/settings-navigation-provider' +import { useSettingsIntentHandler } from '@/components/settings/settings-navigation-provider' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { diff --git a/apps/sim/components/settings/settings-navigation-provider.tsx b/apps/sim/components/settings/settings-navigation-provider.tsx index 084311e750a..e19b6f7d695 100644 --- a/apps/sim/components/settings/settings-navigation-provider.tsx +++ b/apps/sim/components/settings/settings-navigation-provider.tsx @@ -12,11 +12,6 @@ import { useTransition, } from 'react' import { useRouter } from 'next/navigation' -import { - type SettingsHeaderMeta, - SettingsHeaderProvider, - SettingsHeaderShell, -} from '@/components/settings/settings-header' interface SettingsNavigationState { /** The section a sidebar click is navigating to, until that navigation settles. */ @@ -97,39 +92,3 @@ export function useSettingsIntentHandler(handler: (section: string) => void): vo const { registerIntentHandler } = useSettingsNavigationState() useEffect(() => registerIntentHandler(handler), [registerIntentHandler, handler]) } - -interface SettingsPendingSectionProps { - /** The header a pending section paints with, or null to keep showing the current section. */ - resolveMeta: (section: string) => SettingsHeaderMeta | null - children: ReactNode -} - -/** - * Paints a clicked section's heading over an empty body while its route resolves — the same frame - * the route commits with, minus the body — so the click lands immediately. The current section - * stays mounted and laid out underneath, invisible and inert, so its scroll position and state are - * intact if the navigation settles back on it. With nothing pending both wrappers are - * `display: contents` and add no box of their own. - */ -export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) { - const { pendingSection } = useSettingsNavigationState() - const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null - - return ( -
- {pendingMeta && ( -
- - {null} - -
- )} -
- {children} -
-
- ) -} diff --git a/apps/sim/components/settings/settings-pending-section.tsx b/apps/sim/components/settings/settings-pending-section.tsx new file mode 100644 index 00000000000..6ff1616025f --- /dev/null +++ b/apps/sim/components/settings/settings-pending-section.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { ReactNode } from 'react' +import { + type SettingsHeaderMeta, + SettingsHeaderProvider, + SettingsHeaderShell, +} from '@/components/settings/settings-header' +import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider' + +interface SettingsPendingSectionProps { + /** The header a pending section paints with, or null to keep showing the current section. */ + resolveMeta: (section: string) => SettingsHeaderMeta | null + children: ReactNode +} + +/** + * Paints a clicked section's heading over an empty body while its route resolves — the same frame + * the route commits with, minus the body — so the click lands immediately. The current section + * stays mounted and laid out underneath, invisible and inert, so its scroll position and state are + * intact if the navigation settles back on it. With nothing pending both wrappers are + * `display: contents` and add no box of their own. + */ +export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) { + const { pendingSection } = useSettingsNavigationState() + const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null + + return ( +
+ {pendingMeta && ( +
+ + {null} + +
+ )} +
+ {children} +
+
+ ) +} diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index febcd825799..35ec77e0f8f 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -17,11 +17,9 @@ import { toSettingsHeaderMeta, } from '@/components/settings/navigation' import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' -import { - SettingsNavigationProvider, - SettingsPendingSection, -} from '@/components/settings/settings-navigation-provider' +import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { SettingsSectionProvider } from '@/components/settings/settings-panel' +import { SettingsPendingSection } from '@/components/settings/settings-pending-section' import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload' import type { DeploymentShape } from '@/lib/api/contracts/workspaces' diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx index 3835cd0d804..8afda8d40c0 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx @@ -18,6 +18,7 @@ import { ARCHIVED_PREVIEW_LIMIT, useForkSync, } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' interface ForkSyncDetailViewProps { @@ -71,6 +72,12 @@ export function ForkSyncDetailView({ const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) + // A direction switch drops every in-session choice (see `useForkSync`), so any confirms first. + const [pendingDirection, setPendingDirection] = useState(null) + const changeDirection = (next: ForkDirection) => { + if (controller.hasSessionChoices) setPendingDirection(next) + else void setDirection(next) + } // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster @@ -112,11 +119,7 @@ export function ForkSyncDetailView({ title={title} actions={panelActions} > - guard.guardBack(() => void setDirection(next))} - /> + + { + if (!open) setPendingDirection(null) + }} + onDiscard={() => { + if (pendingDirection) void setDirection(pendingDirection) + setPendingDirection(null) + }} + /> + void discard: () => void @@ -769,6 +775,20 @@ export function useForkSync(params: { const dirty = targetsDirty || reconfigDirty + const copySelectionChanged = useMemo(() => { + if (!copyDefaulted) return false + const defaults = forkDefaultCopySelection(copyableUnmapped) + if (defaults.size !== copySelected.size) return true + for (const key of copySelected) if (!defaults.has(key)) return true + return false + }, [copyDefaulted, copyableUnmapped, copySelected]) + + const hasSessionChoices = + dirty || + copySelectionChanged || + droppedRefs.size > 0 || + Object.keys(triggerAdoptions).length > 0 + const save = () => { if (!otherWorkspaceId || !dirty || updateMapping.isPending) return const submittedTargets = targets @@ -1059,6 +1079,7 @@ export function useForkSync(params: { mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0, inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0, dirty, + hasSessionChoices, saving: updateMapping.isPending, save, discard, From 4ca8b3acc111fe70139b628631faa01eaed9f7de Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 18:47:19 -0700 Subject: [PATCH 4/5] fix(fork-sync): clear committed session choices after a sync A successful sync applies the accepted drops and trigger URL choices, so clear exactly the snapshots it submitted, as it already does for mapping edits; choices made while the request was in flight stay. Compare the copy selection over the visible candidates only, the ones a sync sends, so keys left behind by a completed copy never count as a change. --- .../components/fork-sync/use-fork-sync.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index a7792fe5aee..c4de4a5d719 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -775,13 +775,16 @@ export function useForkSync(params: { const dirty = targetsDirty || reconfigDirty - const copySelectionChanged = useMemo(() => { - if (!copyDefaulted) return false - const defaults = forkDefaultCopySelection(copyableUnmapped) - if (defaults.size !== copySelected.size) return true - for (const key of copySelected) if (!defaults.has(key)) return true - return false - }, [copyDefaulted, copyableUnmapped, copySelected]) + // Compared over the visible candidates only - the ones a sync would send - so keys left behind + // by a completed copy never read as a change. A candidate defaults to selected when referenced. + const copySelectionChanged = useMemo( + () => + copyDefaulted && + visibleCopyables.some( + (candidate) => copySelected.has(forkRefKey(candidate)) !== candidate.referenced + ), + [copyDefaulted, visibleCopyables, copySelected] + ) const hasSessionChoices = dirty || @@ -887,6 +890,8 @@ export function useForkSync(params: { setSubmitting(true) const submittedTargets = targets const submittedReconfig = reconfig + const submittedDroppedRefs = droppedRefs + const submittedTriggerAdoptions = triggerAdoptions // Capture every payload from the state at confirm time, before any await - the page's // controls stay mounted during the run (unlike the old modal, which blocked its UI), so a // mid-flight edit must not leak into the promote body. @@ -982,10 +987,13 @@ export function useForkSync(params: { } // The run committed the in-session choices: the mapping entries and dependent values are - // stored. Drop only the exact snapshots it submitted; edits made while the request was in - // flight were not committed by this run and must remain available for the next Save/Sync. + // stored, and the accepted drops and trigger choices are applied. Drop only the exact + // snapshots it submitted; edits made while the request was in flight were not committed by + // this run and must remain available for the next Save/Sync. setTargets((current) => (current === submittedTargets ? {} : current)) setReconfig((current) => (current === submittedReconfig ? {} : current)) + setDroppedRefs((current) => (current === submittedDroppedRefs ? new Set() : current)) + setTriggerAdoptions((current) => (current === submittedTriggerAdoptions ? {} : current)) const target = otherWorkspaceName || 'the workspace' const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"` From 3c78fa95780252ff16a1bffd80837406d53a3b6b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 18:53:03 -0700 Subject: [PATCH 5/5] chore(settings): rebase module-graph baselines onto staging Record the settings layout's section import() edges and the workspace chrome's smaller graph against the current staging baseline. --- ...check-tool-registry-boundary.baseline.json | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 5fac5b569dc..3e115893bb8 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -269,16 +269,16 @@ } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2363, + "modules": 2298, "gateways": { "apps/sim/triggers/registry.ts": 489, "apps/sim/lib/auth/index.ts": 422, - "apps/sim/blocks/registry.ts": 356, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 351, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 222, + "apps/sim/blocks/registry.ts": 355, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 271, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 142, "apps/sim/lib/webhooks/providers/index.ts": 122, "apps/sim/lib/webhooks/providers/registry.ts": 120, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/index.ts": 91 + "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 90 } }, "app/workspace/[workspaceId]/logs/error.tsx": { @@ -357,8 +357,17 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/layout.tsx": { - "modules": 3, - "gateways": {} + "modules": 1817, + "gateways": { + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 1779, + "apps/sim/triggers/registry.ts": 528, + "apps/sim/blocks/registry.ts": 364, + "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 111, + "apps/sim/ee/access-control/components/access-control.tsx": 79, + "apps/sim/ee/access-control/components/group-detail.tsx": 77, + "apps/sim/connectors/registry.ts": 72, + "apps/sim/triggers/clickup/index.ts": 32 + } }, "app/workspace/[workspaceId]/settings/page.tsx": { "modules": 1,