- {/*
- 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,