Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/add-settings-page/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ Each grep lists candidates; review every match against the expected ones named b
- Editable pages: confirm Save/Discard go through `saveDiscardActions()` and
dirty is wired via `useSettingsUnsavedGuard` (called before early-return
gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal.
`git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'`
should only hit the centralized `use-settings-before-unload.ts`.
`git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' 'apps/sim/components/settings/' ':(exclude,glob)**/*.test.*'`
should only hit the centralized `use-settings-browser-navigation.ts`.
5. Fix each finding with the smallest structural change that satisfies the checklist;
do not touch handlers, state, queries, or gate returns. A pixel-size fix swaps
only the size class for its exact-pixel token (`text-[12px]` → `text-caption`).
Expand Down
55 changes: 27 additions & 28 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,34 +339,33 @@ rendered through `<SettingsActionChips actions={…} />` from
`@/components/settings/settings-header` — that is the shared chip path, and it
is what keeps tone/icon/variant/tooltip handling from drifting between the two
shells. Reach for it before hand-rolling a `Chip`.
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
confirmDiscard }` for a detail view's **in-view back** chip.
- **Top-level pages** (whitelabeling, sso): call it **unassigned** —
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
store-sync; the sidebar/`beforeunload` do the rest.
- **Detail sub-views** (data-retention, access-control group-detail): route the
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a
*reset to original* — distinct from the back-confirm's discard, which leaves.
- **`useSettingsBeforeUnload`** is mounted by the settings shells
(`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) —
never add a per-page `beforeunload`.
- **Dirty *computation* stays local** (shapes differ: field-compare vs
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
`const`/`useMemo`), never store it in `useState`.
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
**unconditionally, before every early-return gate** (entitlement / loading /
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
skipped on gated renders and crashes.
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
guards real `router.push` navigation + browser Back via a history sentinel);
it already shares `UnsavedChangesModal`, so copy stays unified.
- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from
`@/components/settings/use-settings-unsaved-guard` registers one editor with the
shared settings store. Register every full-page draft, including inline creation
forms. Dirty computation stays local and derived; fetched defaults stay clean.
- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard`
owns the discard dialog, internal link interception, browser Back/Forward, and
refresh protection for registered editors. Do not add a per-page dialog or
history listener for an editor using this hook. Skill create/detail pages retain
their existing `useUnsavedChangesGuard` and local dialog until they migrate to
the shared registration.
- **Native history needs entry indexes.** The Navigation API supplies them for
legacy entries. Without it, native traversal to an unindexed entry cannot be
cancelled reliably; never guess a direction or rewrite the history stack.
- **History confirmation authorizes traversal before discarding.** Drafts are
discarded only when the browser reports the confirmed traversal, so a no-op
Back or Forward retains edits. Known cross-document traversal uses native unload
protection. An unknown cross-document target on a classic History browser can
require a second native confirmation after the shared dialog.
- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view
transitions, such as switching an editor's direction or environment, also use
the shared `requestLeave` action before resetting their draft.
- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks
navigation while requests are pending. Attempts are not queued; retry after they
settle. Disable or preserve edits made during requests;
failed saves retain drafts, and successful saves only clear committed values.
- **Call the hook unconditionally, before every early-return gate** (entitlement,
loading, or empty-state return), so gated renders preserve hook order.

## Detail sub-views

Expand Down
55 changes: 27 additions & 28 deletions .cursor/rules/sim-settings-pages.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -336,34 +336,33 @@ rendered through `<SettingsActionChips actions={…} />` from
`@/components/settings/settings-header` — that is the shared chip path, and it
is what keeps tone/icon/variant/tooltip handling from drifting between the two
shells. Reach for it before hand-rolling a `Chip`.
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
confirmDiscard }` for a detail view's **in-view back** chip.
- **Top-level pages** (whitelabeling, sso): call it **unassigned** —
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
store-sync; the sidebar/`beforeunload` do the rest.
- **Detail sub-views** (data-retention, access-control group-detail): route the
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a
*reset to original* — distinct from the back-confirm's discard, which leaves.
- **`useSettingsBeforeUnload`** is mounted by the settings shells
(`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) —
never add a per-page `beforeunload`.
- **Dirty *computation* stays local** (shapes differ: field-compare vs
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
`const`/`useMemo`), never store it in `useState`.
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
**unconditionally, before every early-return gate** (entitlement / loading /
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
skipped on gated renders and crashes.
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
guards real `router.push` navigation + browser Back via a history sentinel);
it already shares `UnsavedChangesModal`, so copy stays unified.
- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from
`@/components/settings/use-settings-unsaved-guard` registers one editor with the
shared settings store. Register every full-page draft, including inline creation
forms. Dirty computation stays local and derived; fetched defaults stay clean.
- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard`
owns the discard dialog, internal link interception, browser Back/Forward, and
refresh protection for registered editors. Do not add a per-page dialog or
history listener for an editor using this hook. Skill create/detail pages retain
their existing `useUnsavedChangesGuard` and local dialog until they migrate to
the shared registration.
- **Native history needs entry indexes.** The Navigation API supplies them for
legacy entries. Without it, native traversal to an unindexed entry cannot be
cancelled reliably; never guess a direction or rewrite the history stack.
- **History confirmation authorizes traversal before discarding.** Drafts are
discarded only when the browser reports the confirmed traversal, so a no-op
Back or Forward retains edits. Known cross-document traversal uses native unload
protection. An unknown cross-document target on a classic History browser can
require a second native confirmation after the shared dialog.
- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view
transitions, such as switching an editor's direction or environment, also use
the shared `requestLeave` action before resetting their draft.
- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks
navigation while requests are pending. Attempts are not queued; retry after they
settle. Disable or preserve edits made during requests;
failed saves retain drafts, and successful saves only clear committed values.
- **Call the hook unconditionally, before every early-return gate** (entitlement,
loading, or empty-state return), so gated renders preserve hook order.

## Detail sub-views

Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata, Viewport } from 'next'
import Script from 'next/script'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { BrandedLayout } from '@/components/branded-layout'
import { SettingsNavigationGuard } from '@/components/settings/settings-navigation-guard'
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
import { BrowserTelemetry } from '@/app/_shell/providers/browser-telemetry'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const themeCSS = generateThemeCSS()
const application = (
<ToastProvider>
<SettingsNavigationGuard />
<DesktopUpdateNotification />
<PasteAdmissionGuard />
<PostHogProvider consentRequired={isHosted}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type { ResourceScope } from '@/lib/core/resource-scope'
import { organizationRoutes } from '@/lib/navigation/paths'
import { describeSearchSource } from '@/lib/sim-search/source-identity'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal'
import { ConnectorActionFeedback } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions'
import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state'
import { useConnectorActions } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'
Expand Down Expand Up @@ -341,7 +340,11 @@ function SourceSettingsForm({
isSearchIndex: true,
onSaved,
})
const guard = useSettingsUnsavedGuard({ isDirty: form.dirty, navigationBlocked: form.saving })
const guard = useSettingsUnsavedGuard({
isDirty: form.dirty,
navigationBlocked: form.saving,
onDiscard,
})
return (
<SourcePanel
connector={connector}
Expand Down Expand Up @@ -370,11 +373,6 @@ function SourceSettingsForm({
<div className='-mx-2 flex flex-col gap-4'>
<ConnectorSettingsFields {...form.fieldsProps} />
</div>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
</SourcePanel>
)
}
2 changes: 0 additions & 2 deletions apps/sim/app/o/[organizationId]/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@

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
}

export default function OrganizationSettingsLayout({ children }: OrganizationSettingsLayoutProps) {
useSettingsBeforeUnload()
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<SettingsPendingSection resolveMeta={resolveOrganizationSurfaceHeaderMeta}>
Expand Down
Loading
Loading