Skip to content

Commit d82a243

Browse files
committed
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.
1 parent 150d896 commit d82a243

7 files changed

Lines changed: 90 additions & 55 deletions

File tree

‎apps/sim/app/o/[organizationId]/settings/layout.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4-
import { SettingsPendingSection } from '@/components/settings/settings-navigation-provider'
4+
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
55
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
66
import { resolveOrganizationSurfaceHeaderMeta } from '@/app/o/[organizationId]/settings/navigation'
77

‎apps/sim/app/workspace/[workspaceId]/settings/layout.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33
import { useCallback } from 'react'
44
import { useQueryClient } from '@tanstack/react-query'
5-
import {
6-
SettingsPendingSection,
7-
useSettingsIntentHandler,
8-
} from '@/components/settings/settings-navigation-provider'
5+
import { useSettingsIntentHandler } from '@/components/settings/settings-navigation-provider'
6+
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
97
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
108
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
119
import {

‎apps/sim/components/settings/settings-navigation-provider.tsx‎

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ import {
1212
useTransition,
1313
} from 'react'
1414
import { useRouter } from 'next/navigation'
15-
import {
16-
type SettingsHeaderMeta,
17-
SettingsHeaderProvider,
18-
SettingsHeaderShell,
19-
} from '@/components/settings/settings-header'
2015

2116
interface SettingsNavigationState {
2217
/** The section a sidebar click is navigating to, until that navigation settles. */
@@ -97,39 +92,3 @@ export function useSettingsIntentHandler(handler: (section: string) => void): vo
9792
const { registerIntentHandler } = useSettingsNavigationState()
9893
useEffect(() => registerIntentHandler(handler), [registerIntentHandler, handler])
9994
}
100-
101-
interface SettingsPendingSectionProps {
102-
/** The header a pending section paints with, or null to keep showing the current section. */
103-
resolveMeta: (section: string) => SettingsHeaderMeta | null
104-
children: ReactNode
105-
}
106-
107-
/**
108-
* Paints a clicked section's heading over an empty body while its route resolves — the same frame
109-
* the route commits with, minus the body — so the click lands immediately. The current section
110-
* stays mounted and laid out underneath, invisible and inert, so its scroll position and state are
111-
* intact if the navigation settles back on it. With nothing pending both wrappers are
112-
* `display: contents` and add no box of their own.
113-
*/
114-
export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) {
115-
const { pendingSection } = useSettingsNavigationState()
116-
const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null
117-
118-
return (
119-
<div className={pendingMeta ? 'relative flex h-full min-h-0 flex-col' : 'contents'}>
120-
{pendingMeta && (
121-
<div className='absolute inset-0 z-10 bg-[var(--bg)]'>
122-
<SettingsHeaderProvider>
123-
<SettingsHeaderShell meta={pendingMeta}>{null}</SettingsHeaderShell>
124-
</SettingsHeaderProvider>
125-
</div>
126-
)}
127-
<div
128-
className={pendingMeta ? 'invisible flex h-full min-h-0 flex-col' : 'contents'}
129-
inert={pendingMeta ? true : undefined}
130-
>
131-
{children}
132-
</div>
133-
</div>
134-
)
135-
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import type { ReactNode } from 'react'
4+
import {
5+
type SettingsHeaderMeta,
6+
SettingsHeaderProvider,
7+
SettingsHeaderShell,
8+
} from '@/components/settings/settings-header'
9+
import { useSettingsNavigationState } from '@/components/settings/settings-navigation-provider'
10+
11+
interface SettingsPendingSectionProps {
12+
/** The header a pending section paints with, or null to keep showing the current section. */
13+
resolveMeta: (section: string) => SettingsHeaderMeta | null
14+
children: ReactNode
15+
}
16+
17+
/**
18+
* Paints a clicked section's heading over an empty body while its route resolves — the same frame
19+
* the route commits with, minus the body — so the click lands immediately. The current section
20+
* stays mounted and laid out underneath, invisible and inert, so its scroll position and state are
21+
* intact if the navigation settles back on it. With nothing pending both wrappers are
22+
* `display: contents` and add no box of their own.
23+
*/
24+
export function SettingsPendingSection({ resolveMeta, children }: SettingsPendingSectionProps) {
25+
const { pendingSection } = useSettingsNavigationState()
26+
const pendingMeta = pendingSection ? resolveMeta(pendingSection) : null
27+
28+
return (
29+
<div className={pendingMeta ? 'relative flex h-full min-h-0 flex-col' : 'contents'}>
30+
{pendingMeta && (
31+
<div className='absolute inset-0 z-10 bg-[var(--bg)]'>
32+
<SettingsHeaderProvider>
33+
<SettingsHeaderShell meta={pendingMeta}>{null}</SettingsHeaderShell>
34+
</SettingsHeaderProvider>
35+
</div>
36+
)}
37+
<div
38+
className={pendingMeta ? 'invisible flex h-full min-h-0 flex-col' : 'contents'}
39+
inert={pendingMeta ? true : undefined}
40+
>
41+
{children}
42+
</div>
43+
</div>
44+
)
45+
}

‎apps/sim/components/settings/standalone-settings-shell.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ import {
1717
toSettingsHeaderMeta,
1818
} from '@/components/settings/navigation'
1919
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
20-
import {
21-
SettingsNavigationProvider,
22-
SettingsPendingSection,
23-
} from '@/components/settings/settings-navigation-provider'
20+
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
2421
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
22+
import { SettingsPendingSection } from '@/components/settings/settings-pending-section'
2523
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
2624
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
2725
import type { DeploymentShape } from '@/lib/api/contracts/workspaces'

‎apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ARCHIVED_PREVIEW_LIMIT,
1919
useForkSync,
2020
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
21+
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
2122
import { buildWebhookTriggerUrl } from '@/triggers/webhook-url'
2223

2324
interface ForkSyncDetailViewProps {
@@ -71,6 +72,12 @@ export function ForkSyncDetailView({
7172
const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty })
7273

7374
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
75+
// A direction switch drops every in-session choice (see `useForkSync`), so any confirms first.
76+
const [pendingDirection, setPendingDirection] = useState<ForkDirection | null>(null)
77+
const changeDirection = (next: ForkDirection) => {
78+
if (controller.hasSessionChoices) setPendingDirection(next)
79+
else void setDirection(next)
80+
}
7481

7582
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
7683
// Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster
@@ -112,11 +119,7 @@ export function ForkSyncDetailView({
112119
title={title}
113120
actions={panelActions}
114121
>
115-
<ForkSyncView
116-
controller={controller}
117-
// A direction switch drops the in-session mapping choices, so unsaved edits confirm first.
118-
onDirectionChange={(next) => guard.guardBack(() => void setDirection(next))}
119-
/>
122+
<ForkSyncView controller={controller} onDirectionChange={changeDirection} />
120123
</SettingsPanel>
121124

122125
<UnsavedChangesModal
@@ -125,6 +128,17 @@ export function ForkSyncDetailView({
125128
onDiscard={guard.confirmDiscard}
126129
/>
127130

131+
<UnsavedChangesModal
132+
open={pendingDirection !== null}
133+
onOpenChange={(open) => {
134+
if (!open) setPendingDirection(null)
135+
}}
136+
onDiscard={() => {
137+
if (pendingDirection) void setDirection(pendingDirection)
138+
setPendingDirection(null)
139+
}}
140+
/>
141+
128142
<ChipConfirmModal
129143
open={confirmSyncOpen}
130144
onOpenChange={setConfirmSyncOpen}

‎apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ export interface ForkSyncController {
220220
mcpReauthCount: number
221221
inlineSecretCount: number
222222
dirty: boolean
223+
/**
224+
* Whether a direction switch would discard anything chosen this session: unsaved mapping edits,
225+
* a copy selection that differs from the default, accepted dropped references, or trigger URL
226+
* choices. Broader than {@link dirty}, which only tracks what Save persists.
227+
*/
228+
hasSessionChoices: boolean
223229
saving: boolean
224230
save: () => void
225231
discard: () => void
@@ -769,6 +775,20 @@ export function useForkSync(params: {
769775

770776
const dirty = targetsDirty || reconfigDirty
771777

778+
const copySelectionChanged = useMemo(() => {
779+
if (!copyDefaulted) return false
780+
const defaults = forkDefaultCopySelection(copyableUnmapped)
781+
if (defaults.size !== copySelected.size) return true
782+
for (const key of copySelected) if (!defaults.has(key)) return true
783+
return false
784+
}, [copyDefaulted, copyableUnmapped, copySelected])
785+
786+
const hasSessionChoices =
787+
dirty ||
788+
copySelectionChanged ||
789+
droppedRefs.size > 0 ||
790+
Object.keys(triggerAdoptions).length > 0
791+
772792
const save = () => {
773793
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
774794
const submittedTargets = targets
@@ -1059,6 +1079,7 @@ export function useForkSync(params: {
10591079
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
10601080
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
10611081
dirty,
1082+
hasSessionChoices,
10621083
saving: updateMapping.isPending,
10631084
save,
10641085
discard,

0 commit comments

Comments
 (0)