diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md
index 1f666fc59f2..f2e7a5f63b5 100644
--- a/.claude/rules/sim-settings-pages.md
+++ b/.claude/rules/sim-settings-pages.md
@@ -6,14 +6,18 @@ paths:
# Settings Pages
-Every settings page renders through the shared **`SettingsPanel`** primitive
-(`@/app/workspace/[workspaceId]/settings/components/settings-panel`). It owns the
-page chrome so pages never hand-roll it: a fixed header bar (right-aligned
-actions), a scroll region, and a centered `max-w-[48rem]` content column led by a
-**title + description that come from navigation metadata**. Pages render only
-their body.
-
-Do NOT hand-roll any of these in a settings page — they are the panel's job:
+The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
+`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
+action chips), a scroll region, and a centered `max-w-[48rem]` content column led
+by a **title + description from navigation metadata**. The chrome stays mounted
+across section navigation (it never re-renders or re-lays-out). Each section
+renders through the **`SettingsPanel`** registrar
+(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
+the shell its header data and renders only the section body. Sections supply
+**data**, never chrome.
+
+Do NOT hand-roll any of these in a settings page — they are owned by the layout
+shell (fed through `SettingsPanel`):
- `
` shell
- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`)
@@ -29,11 +33,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
return (
- Create
-
- }
+ actions={[{ text: 'Create', icon: Plus, variant: 'primary', onSelect: onCreate }]}
search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }}
>
{/* body only — sections, lists, forms */}
@@ -54,9 +54,22 @@ return (
## `SettingsPanel` props
-- `actions?: ReactNode` — right-aligned header chips. Wrap multiple in a fragment;
- the slot reserves the 30px chip height even when empty, so vertical rhythm is
- identical across pages. Conditional actions are fine: `actions={canManage && }`.
+- `actions?: SettingsAction[]` — right-aligned header chips, **data only**:
+ `{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`.
+ The shell renders each as a `Chip` — never pass JSX, a `
`, or `className`
+ (the locked contract: it's structurally impossible to vibe-code a padding
+ change). Multiple/conditional actions are a plain array
+ (`[...(canManage ? [{…}] : []), …]`). Labels are **sentence case** (`Add override`,
+ not `Add Override`). A disabled action that needs to explain itself sets
+ `tooltip` (the shell renders the hover tooltip, disabled chip included) — never
+ hand-roll a tooltip-wrapped chip in `aside`. Save/Discard pairs come from the
+ `saveDiscardActions()` helper (spread it into `actions`). Only a widget that
+ genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in `aside`.
+- `back?: SettingsBackAction` (`{ text, icon?, onSelect }`) — left-aligned back
+ chip for a **detail sub-view** (e.g. a selected MCP server, a permission group,
+ a retention policy). Detail sub-views render through `SettingsPanel` like list
+ pages — they do NOT hand-roll their own shell.
+- `aside?: ReactNode` — escape hatch for the rare non-chip header widget. Keep it rare.
- `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` —
renders the canonical search field directly below the title. Pass `setSearchTerm`
straight to `onChange`. Use this for a standalone search; if search shares a row
@@ -66,8 +79,6 @@ return (
detail sub-view that needs a different heading; normal pages never pass these.
- `scrollContainerRef?: React.Ref` — forwards a ref to the scroll
region (e.g. programmatic scroll-to-bottom).
-- `contentClassName?` — layout/spacing only; reach for it rarely. Prefer the
- default `gap-7`.
## Title + description live in navigation metadata
@@ -107,12 +118,11 @@ Any settings surface with editable state uses **one** shared stack — never
hand-roll a Save button, a Discard button, a `beforeunload`, or an "Unsaved
changes" modal:
-- **`SaveDiscardActions`** (`…/components/save-discard-actions/save-discard-actions`)
- — the canonical dirty-gated **Discard + Save** chip pair. Renders nothing when
- `!dirty`; otherwise a fragment so it composes beside sibling chips (a detail
- view's Delete / Remove override, a Share chip). Props: `dirty`, `saving`,
- `onSave`, `onDiscard`, `saveDisabled?`, `saveLabel?`, `savingLabel?`. Put it in
- the `SettingsPanel actions` slot (top-level pages) or the detail header bar.
+- **`saveDiscardActions(config)`** (`…/components/save-discard-actions/save-discard-actions`)
+ — returns the canonical dirty-gated **Discard + Save** `SettingsAction[]` (empty
+ when not dirty). Spread it into a `SettingsPanel` `actions` array, beside any
+ sibling actions (a detail view's Delete / Remove override). Config: `dirty`,
+ `saving`, `onSave`, `onDiscard`, `saveDisabled?`, `saveLabel?`, `savingLabel?`.
- **`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
@@ -141,14 +151,18 @@ changes" modal:
guards real `router.push` navigation + browser Back via a history sentinel);
it already shares `UnsavedChangesModal`, so copy stays unified.
-## Detail sub-views (the one exception)
+## Detail sub-views
A drill-down view reached from a list row (selected MCP server, workflow MCP
-server, credential set, permission group) keeps its **own** chrome because it
-needs a left-aligned back button (``), which the panel
-header (right-actions only) does not model. Leave those returns as hand-rolled
-shells; only the list/main view uses `SettingsPanel`. Gate/early-return states
-(not-entitled, loading, upgrade prompts) also stay as-is.
+server, credential set, permission group, retention policy) renders through
+`SettingsPanel` like a list page: pass `back={{ text, icon: ArrowLeft, onSelect }}`
+for the left back chip, `title` (the entity name), and the header `actions`, then
+render the body. Do NOT hand-roll a shell or header bar; a tab bar renders as the
+first body child. Gate/early-return states (not-entitled, loading, upgrade
+prompts) stay as-is.
+
+The route-based credential detail (`settings/secrets/[credentialId]`) is the lone
+exception — it lives outside `[section]` and keeps its own `CredentialDetailLayout`.
## Audit checklist
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
new file mode 100644
index 00000000000..6ab029f7f5b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
@@ -0,0 +1,18 @@
+import {
+ SettingsHeaderProvider,
+ SettingsHeaderShell,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
+
+/**
+ * Persistent chrome for the settings panel pages. The header bar, title,
+ * description, scroll region, and centered column live in the shell and stay
+ * mounted across section navigation — only the body swaps. Scoped to `[section]`
+ * so detail routes (e.g. `secrets/[credentialId]`) keep their own chrome.
+ */
+export default function SettingsSectionLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index 7f4dcf3f1f2..b2fc4a7daa5 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -129,30 +129,28 @@ export function SettingsPage({ section }: SettingsPageProps) {
return (
-
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
index 19bb8a7d5dc..9ec6ea696c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
@@ -1,7 +1,12 @@
'use client'
-import { createContext, type ReactNode, useContext } from 'react'
-import { ChipInput, ChipLink, cn, Search } from '@sim/emcn'
+import { createContext, type ReactNode, type Ref, useContext } from 'react'
+import {
+ type SettingsAction,
+ type SettingsBackAction,
+ type SettingsHeaderSearch,
+ useSettingsHeader,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import {
getSettingsSectionMeta,
type SettingsSection,
@@ -30,105 +35,59 @@ function useSettingsSection(): SettingsSection | null {
return useContext(SettingsSectionContext)
}
-interface SettingsPanelSearch {
- value: string
- onChange: (value: string) => void
- placeholder?: string
- disabled?: boolean
-}
-
interface SettingsPanelProps {
/** Body content rendered below the header in the centered content column. */
- children: ReactNode
- /** Right-aligned controls in the fixed header bar (e.g. a Create/Invite chip). */
- actions?: ReactNode
+ children?: ReactNode
+ /** Strict top-right action chips — data only (`text`/`icon`/`variant`/…), never JSX. */
+ actions?: SettingsAction[]
+ /** Left-aligned back chip for a detail sub-view; omit on list/panel pages. */
+ back?: SettingsBackAction
+ /** Renders the canonical search field directly below the title. */
+ search?: SettingsHeaderSearch
/** Overrides the nav-driven title (e.g. for a detail sub-view). */
title?: string
- /** Overrides the nav-driven description. */
- description?: string
- /** Overrides the nav-driven docs link (the "Docs" link rendered on the title row). */
- docsLink?: string
- /** Extra classes for the content column (layout/spacing only, e.g. a tighter `gap-*`). */
- contentClassName?: string
- /** Ref forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
- scrollContainerRef?: React.Ref
/**
- * Renders the canonical search field directly below the title. Omit on pages
- * with no search, or that pair search with extra controls (render that row in
- * `children` instead).
+ * Overrides the nav-driven description. When `title` is set (a detail sub-view),
+ * the description is used verbatim — it never falls back to the section's meta
+ * blurb, so an entity with no description renders no description.
*/
- search?: SettingsPanelSearch
+ description?: string
+ /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */
+ docsLink?: string
+ /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
+ scrollContainerRef?: Ref
}
/**
- * Standard chrome for a settings page: a fixed header bar (right-aligned
- * `actions`), a scroll region, and a centered content column led by the page
- * title + description. The title/description come from the active section's
- * navigation metadata by default, and can be overridden for sub-views.
- *
- * Pages render only their body as `children`; they no longer hand-roll the
- * shell, header bar, or title block.
+ * Registers a settings section's header content (title, description, docs link,
+ * action chips, search) into the persistent settings layout, then renders the
+ * section body. It owns **no** chrome: the header bar, scroll region, centered
+ * column, and spacing all live in the layout's `SettingsHeaderShell` and stay
+ * mounted across section navigation. Sections supply data only — the structured
+ * `actions` contract makes it impossible to inject a `
+
+ Applies to everyone in the organization not assigned to another group, including
+ external workspace members
+
+ handleToggleDefault(checked)}
+ disabled={updatePermissionGroup.isPending}
+ />
+
+
+
+
+ {viewingGroup.isDefault ? (
+
+
+ Governs every workspace in the organization
+
+
- Applies to everyone in the organization not assigned to another group,
- including external workspace members
+ {members.length === 0
+ ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.'
+ : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`}
- handleToggleDefault(checked)}
- disabled={updatePermissionGroup.isPending}
- />
+
+ Add
+
-
-
-
- {viewingGroup.isDefault ? (
-
-
- Governs every workspace in the organization
-
+ {membersLoading ? (
+
-
- {members.length === 0
- ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.'
- : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`}
-
-
- Add
-
-