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..84e588433a7 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-pending-section' 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..e1499f22311 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -26,19 +26,19 @@ 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 * 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 - * this layout (a `settings/loading.tsx`, or a `` in the settings layout) would also - * swallow the 404 and 307 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. * * Authentication is already enforced by the ancestor workspace layout, so this runs only for a * signed-in viewer. 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..ed92a72a23a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx @@ -1,8 +1,48 @@ 'use client' +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +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 { + 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-navigation-provider.tsx b/apps/sim/components/settings/settings-navigation-provider.tsx new file mode 100644 index 00000000000..e19b6f7d695 --- /dev/null +++ b/apps/sim/components/settings/settings-navigation-provider.tsx @@ -0,0 +1,94 @@ +'use client' + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useOptimistic, + useRef, + useTransition, +} from 'react' +import { useRouter } from 'next/navigation' + +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) + +interface SettingsNavigationProviderProps { + children: ReactNode +} + +/** + * 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 }: SettingsNavigationProviderProps) { + 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]) +} 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 61b01132f72..35ec77e0f8f 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -9,13 +9,17 @@ 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 } 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' @@ -41,6 +45,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 +105,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), + } } 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..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,10 +119,7 @@ export function ForkSyncDetailView({ title={title} actions={panelActions} > - void setDirection(next)} - /> + + { + if (!open) setPendingDirection(null) + }} + onDiscard={() => { + if (pendingDirection) void setDirection(pendingDirection) + setPendingDirection(null) + }} + /> + void discard: () => void @@ -769,6 +775,23 @@ export function useForkSync(params: { const dirty = targetsDirty || reconfigDirty + // 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 || + copySelectionChanged || + droppedRefs.size > 0 || + Object.keys(triggerAdoptions).length > 0 + const save = () => { if (!otherWorkspaceId || !dirty || updateMapping.isPending) return const submittedTargets = targets @@ -867,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. @@ -962,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}"` @@ -1059,6 +1087,7 @@ export function useForkSync(params: { mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0, inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0, dirty, + hasSessionChoices, saving: updateMapping.isPending, save, discard, 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,