From cbfa3792e93381b503b34ea0a0b6a1affd30df87 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 19:50:50 -0700 Subject: [PATCH 1/6] improvement(settings): persistent layout + locked-down header API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Resource compound component's locked invariant for settings. - Move ALL page chrome (header bar, title/description, scroll region, centered max-w-[48rem] column, spacing) into the Next.js settings/[section]/layout.tsx via SettingsHeaderShell, so it persists across section navigation and never re-renders / re-lays-out. SettingsPanel becomes a thin registrar that feeds the shell its header data via a React context slot. - Lock the header API: actions is now SettingsAction[] (data only: text/icon/variant/active/onSelect/disabled, rendered as Chips) — no JSX, no
, no className, so a padding change is structurally impossible. Add a left 'back' slot for detail sub-views + an 'aside' escape hatch for the rare non-chip widget. - SaveDiscardActions component -> saveDiscardActions() helper returning the dirty-gated SettingsAction[]. - Migrate every panel page + the 5 in-section detail views (access-control group-detail, data-retention PolicyDetail, mcp, credential-sets, workflow-mcp) onto the layout/back-slot; they no longer hand-roll their own shells. - Chip labels are sentence case (Add override, Create server, ...); the action gap is owned once by the shell; no action
s. Documents the new contract in the settings rule + add-settings-page skill. --- .claude/rules/sim-settings-pages.md | 72 +- .../settings/[section]/layout.tsx | 18 + .../settings/[section]/settings.tsx | 46 +- .../settings/components/api-keys/api-keys.tsx | 30 +- .../settings/components/copilot/copilot.tsx | 28 +- .../credential-sets/credential-sets.tsx | 346 ++++----- .../components/custom-tools/custom-tools.tsx | 24 +- .../settings/components/general/general.tsx | 37 +- .../settings/components/mcp/mcp.tsx | 382 +++++----- .../save-discard-actions.ts | 33 + .../save-discard-actions.tsx | 42 -- .../secrets-manager/secrets-manager.tsx | 63 +- .../settings-header/settings-header.tsx | 184 +++++ .../settings-panel/settings-panel.tsx | 119 +-- .../team-management/team-management.tsx | 22 +- .../components/teammates/teammates.tsx | 2 +- .../workflow-mcp-servers.tsx | 533 +++++++------- .../components/access-control.tsx | 16 +- .../components/group-detail.tsx | 679 +++++++++--------- .../components/data-drains-settings.tsx | 16 +- .../components/data-retention-settings.tsx | 234 +++--- apps/sim/ee/sso/components/sso-settings.tsx | 52 +- .../components/whitelabeling-settings.tsx | 18 +- 23 files changed, 1556 insertions(+), 1440 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 1f666fc59f2..b479f6d4ae2 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,20 @@ 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? }`. + 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`). Save/Discard pairs come from the `saveDiscardActions()` + helper (spread it into `actions`). A widget that genuinely cannot be a chip + (tooltip-wrapped chip, custom dropdown) goes in the `aside` escape hatch. +- `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 +77,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 +116,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 +149,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 ( -
- {effectiveSection === 'general' && } - {effectiveSection === 'secrets' && } - {effectiveSection === 'credential-sets' && } - {effectiveSection === 'access-control' && } - {effectiveSection === 'audit-logs' && } - {effectiveSection === 'apikeys' && } - {isBillingEnabled && effectiveSection === 'billing' && } - {effectiveSection === 'teammates' && } - {isBillingEnabled && effectiveSection === 'organization' && } - {effectiveSection === 'sso' && } - {effectiveSection === 'data-retention' && } - {effectiveSection === 'data-drains' && } - {effectiveSection === 'whitelabeling' && } - {effectiveSection === 'byok' && } - {effectiveSection === 'copilot' && } - {effectiveSection === 'mcp' && } - {effectiveSection === 'custom-tools' && } - {effectiveSection === 'workflow-mcp-servers' && } - {effectiveSection === 'inbox' && } - {effectiveSection === 'recently-deleted' && } - {effectiveSection === 'admin' && } - {effectiveSection === 'mothership' && } -
+ {effectiveSection === 'general' && } + {effectiveSection === 'secrets' && } + {effectiveSection === 'credential-sets' && } + {effectiveSection === 'access-control' && } + {effectiveSection === 'audit-logs' && } + {effectiveSection === 'apikeys' && } + {isBillingEnabled && effectiveSection === 'billing' && } + {effectiveSection === 'teammates' && } + {isBillingEnabled && effectiveSection === 'organization' && } + {effectiveSection === 'sso' && } + {effectiveSection === 'data-retention' && } + {effectiveSection === 'data-drains' && } + {effectiveSection === 'whitelabeling' && } + {effectiveSection === 'byok' && } + {effectiveSection === 'copilot' && } + {effectiveSection === 'mcp' && } + {effectiveSection === 'custom-tools' && } + {effectiveSection === 'workflow-mcp-servers' && } + {effectiveSection === 'inbox' && } + {effectiveSection === 'recently-deleted' && } + {effectiveSection === 'admin' && } + {effectiveSection === 'mothership' && }
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index fa68f11d028..571911f3630 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { Chip, ChipConfirmModal, Switch, Tooltip, toast } from '@sim/emcn' +import { ChipConfirmModal, Switch, Tooltip, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Info, Plus } from 'lucide-react' @@ -10,6 +10,7 @@ import { useSession } from '@/lib/auth/auth-client' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { @@ -132,6 +133,19 @@ export function ApiKeys() { return formatDate(new Date(dateString)) } + const actions: SettingsAction[] = [ + { + text: 'Create API key', + icon: Plus, + variant: 'primary', + onSelect: () => { + if (createButtonDisabled) return + setIsCreateDialogOpen(true) + }, + disabled: createButtonDisabled, + }, + ] + return ( <> { - if (createButtonDisabled) return - setIsCreateDialogOpen(true) - }} - disabled={createButtonDisabled} - > - Create API Key -
- } + actions={actions} > {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( Click "Create API Key" above to get started diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index d824ffe865f..c29abab3127 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { type CopilotKey, @@ -103,6 +104,19 @@ export function Copilot() { const showEmptyState = !hasKeys const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 + const actions: SettingsAction[] = [ + { + text: 'Create API key', + icon: Plus, + variant: 'primary', + onSelect: () => { + setIsCreateDialogOpen(true) + setCreateError(null) + }, + disabled: isLoading, + }, + ] + return ( <> { - setIsCreateDialogOpen(true) - setCreateError(null) - }} - disabled={isLoading} - > - Create API Key - - } + actions={actions} > {isLoading ? null : showEmptyState ? ( Click "Create API Key" above to get started diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index ecc76dc6198..d6a07ce04bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -33,6 +33,7 @@ import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { @@ -420,186 +421,180 @@ export function CredentialSets() { const totalCount = activeMembers.length + pendingInvitations.length return ( -
-
- - Sim Mailer - -
-
- -
-
- -
-
- Group Name - {viewingSet.name} -
-
-
- Provider -
- {getProviderIcon(viewingSet.providerId)} - - {getProviderDisplayName(viewingSet.providerId as PollingProvider)} - -
-
+ +
+ +
+
+ Group Name + {viewingSet.name}
- - - -
-
- addEmail(value)} - onRemove={removeEmailItem} - placeholder='Enter email addresses' - placeholderWithTags='Add another email' - disabled={createInvitation.isPending} - fileInputOptions={fileInputOptions} - className='flex-1' - /> - - {createInvitation.isPending ? 'Sending...' : 'Invite'} - +
+
+ Provider +
+ {getProviderIcon(viewingSet.providerId)} + + {getProviderDisplayName(viewingSet.providerId as PollingProvider)} +
- {emailError &&

{emailError}

}
- - - - {membersLoading || pendingInvitationsLoading ? null : totalCount === 0 ? ( -

- No members yet. Send invitations above. -

- ) : ( -
- {activeMembers.map((member) => { - const name = member.userName || 'Unknown' - const avatarInitial = name.charAt(0).toUpperCase() - - return ( -
-
- - {member.userImage && } - - {avatarInitial} - - - -
-
- - {name} - - {member.credentials.length === 0 && ( - - Disconnected - - )} -
-
- {member.userEmail} -
+
+ + + +
+
+ addEmail(value)} + onRemove={removeEmailItem} + placeholder='Enter email addresses' + placeholderWithTags='Add another email' + disabled={createInvitation.isPending} + fileInputOptions={fileInputOptions} + className='flex-1' + /> + + {createInvitation.isPending ? 'Sending...' : 'Invite'} + +
+ {emailError &&

{emailError}

} +
+
+ + + {membersLoading || pendingInvitationsLoading ? null : totalCount === 0 ? ( +

+ No members yet. Send invitations above. +

+ ) : ( +
+ {activeMembers.map((member) => { + const name = member.userName || 'Unknown' + const avatarInitial = name.charAt(0).toUpperCase() + + return ( +
+
+ + {member.userImage && } + + {avatarInitial} + + + +
+
+ + {name} + + {member.credentials.length === 0 && ( + + Disconnected + + )} +
+
+ {member.userEmail}
+
-
- handleRemoveMember(member.id), - }, - ]} - /> -
+
+ handleRemoveMember(member.id), + }, + ]} + />
- ) - })} - - {pendingInvitations.map((invitation) => { - const email = invitation.email || 'Unknown' - const emailPrefix = email.split('@')[0] - const avatarInitial = emailPrefix.charAt(0).toUpperCase() - - return ( -
-
- - - {avatarInitial} - - - -
-
- - {emailPrefix} - - - Pending - -
-
- {email} -
+
+ ) + })} + + {pendingInvitations.map((invitation) => { + const email = invitation.email || 'Unknown' + const emailPrefix = email.split('@')[0] + const avatarInitial = emailPrefix.charAt(0).toUpperCase() + + return ( +
+
+ + + {avatarInitial} + + + +
+
+ + {emailPrefix} + + + Pending + +
+
+ {email}
+
-
- 0, - onSelect: () => handleResendInvitation(invitation.id, email), - }, - { - label: cancellingInvitations.has(invitation.id) - ? 'Cancelling...' - : 'Cancel', - destructive: true, - disabled: cancellingInvitations.has(invitation.id), - onSelect: () => handleCancelInvitation(invitation.id), - }, - ]} - /> -
+
+ 0, + onSelect: () => handleResendInvitation(invitation.id, email), + }, + { + label: cancellingInvitations.has(invitation.id) + ? 'Cancelling...' + : 'Cancel', + destructive: true, + disabled: cancellingInvitations.has(invitation.id), + onSelect: () => handleCancelInvitation(invitation.id), + }, + ]} + />
- ) - })} -
- )} - -
+
+ ) + })} +
+ )} +
-
+ ) } @@ -612,11 +607,16 @@ export function CredentialSets() { placeholder: 'Search polling groups...', }} actions={ - canManageCredentialSets && ( - setShowCreateModal(true)}> - Create Group - - ) + canManageCredentialSets + ? ([ + { + text: 'Create group', + icon: Plus, + variant: 'primary', + onSelect: () => setShowCreateModal(true), + }, + ] satisfies SettingsAction[]) + : undefined } >
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index c81fd6a8992..3d683fd3dfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -1,13 +1,14 @@ 'use client' import { useState } from 'react' -import { Chip, ChipConfirmModal } from '@sim/emcn' +import { ChipConfirmModal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' @@ -86,6 +87,16 @@ export function CustomTools() { const showEmptyState = !hasTools && !showAddForm && !editingTool const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 + const actions: SettingsAction[] = [ + { + text: 'Add tool', + icon: Plus, + variant: 'primary', + onSelect: () => setShowAddForm(true), + disabled: isLoading, + }, + ] + return ( <> setShowAddForm(true)} - disabled={isLoading} - > - Add Tool - - } + actions={actions} > {error ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index cb66db4945f..4d38140a26b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react' import { Button, - Chip, ChipCombobox, ChipModal, ChipModalBody, @@ -29,6 +28,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/env-flags' import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload' @@ -269,25 +269,26 @@ export function General() { return null } + const actions: SettingsAction[] = [ + ...(isHosted + ? [ + { + text: 'Home page', + onSelect: () => window.open('/?home', '_blank', 'noopener,noreferrer'), + }, + ] + : []), + ...(!isAuthDisabled + ? [ + { text: 'Sign out', onSelect: handleSignOut }, + { text: 'Reset password', onSelect: () => setShowResetPasswordModal(true) }, + ] + : []), + ] + return ( <> - - {isHosted && ( - window.open('/?home', '_blank', 'noopener,noreferrer')}> - Home Page - - )} - {!isAuthDisabled && ( - <> - Sign out - setShowResetPasswordModal(true)}>Reset password - - )} - - } - > +
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index bd6049de24b..358a2f0bcb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -23,6 +23,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' @@ -381,202 +382,194 @@ export function MCP() { ? refreshedWorkflowsUpdated ? `Synced (${refreshedWorkflowsUpdated} workflow${refreshedWorkflowsUpdated === 1 ? '' : 's'})` : 'Refreshed' - : 'Refresh Tools' + : 'Refresh tools' return ( -
-
- - MCP Tools - -
- handleRefreshServer(server.id)} - disabled={refreshingServerId === server.id || refreshedServerId === server.id} - > - {refreshLabel} - - setEditingServerId(server.id)}>Edit -
-
+ handleRefreshServer(server.id), + disabled: refreshingServerId === server.id || refreshedServerId === server.id, + }, + { + text: 'Edit', + onSelect: () => setEditingServerId(server.id), + }, + ] satisfies SettingsAction[] + } + > + +
+
+ Server Name +

+ {server.name || 'Unnamed Server'} +

+
+ +
+ Transport +

{transportLabel}

+
+ + {server.url && ( +
+ URL +

{server.url}

+
+ )} -
-
- -
-
- Server Name -

- {server.name || 'Unnamed Server'} -

-
+ {server.connectionStatus === 'error' && ( +
+ Status +

+ {server.lastError || 'Unable to connect'} +

+
+ )} -
- Transport -

{transportLabel}

+ {server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( +
+ Authentication +
+ { + await startOauthForServer(server.id) + }} + > + {connectingOauthServers.has(server.id) ? 'Connecting…' : 'Connect with OAuth'} +
- - {server.url && ( -
- URL -

{server.url}

-
- )} - - {server.connectionStatus === 'error' && ( -
- Status -

- {server.lastError || 'Unable to connect'} -

-
- )} - - {server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( -
- Authentication -
- { - await startOauthForServer(server.id) - }} - > - {connectingOauthServers.has(server.id) - ? 'Connecting…' - : 'Connect with OAuth'} - -
-
- )}
- - - - {tools.length === 0 ? ( -

No tools available

- ) : ( -
- {tools.map((tool) => { - const issues = getStoredToolIssues(server.id, tool.name) - const affectedWorkflows = issues.map((i) => i.workflowName) - const isExpanded = expandedTools.has(tool.name) - const hasParams = - tool.inputSchema?.properties && - Object.keys(tool.inputSchema.properties).length > 0 - const requiredParams = tool.inputSchema?.required || [] - - return ( -
-
+ + + + {tools.length === 0 ? ( +

No tools available

+ ) : ( +
+ {tools.map((tool) => { + const issues = getStoredToolIssues(server.id, tool.name) + const affectedWorkflows = issues.map((i) => i.workflowName) + const isExpanded = expandedTools.has(tool.name) + const hasParams = + tool.inputSchema?.properties && + Object.keys(tool.inputSchema.properties).length > 0 + const requiredParams = tool.inputSchema?.required || [] + + return ( +
+ + + {isExpanded && hasParams && ( +
+

+ Parameters +

+
+ {Object.entries(tool.inputSchema!.properties!).map( + ([paramName, param]) => { + const isRequired = requiredParams.includes(paramName) + const paramType = + typeof param === 'object' && param !== null + ? (param as { type?: string }).type || 'any' + : 'any' + const paramDesc = + typeof param === 'object' && param !== null + ? (param as { description?: string }).description + : undefined + + return ( +
+
+ + {paramName} + + + {paramType} + + {isRequired && ( + + required -
- - - Update in: {affectedWorkflows.join(', ')} - - - )} -
- {tool.description && ( -

- {tool.description} -

- )} -
- {hasParams && ( - + )} +
+ {paramDesc && ( +

+ {paramDesc} +

+ )} +
+ ) + } )} - - - {isExpanded && hasParams && ( -
-

- Parameters -

-
- {Object.entries(tool.inputSchema!.properties!).map( - ([paramName, param]) => { - const isRequired = requiredParams.includes(paramName) - const paramType = - typeof param === 'object' && param !== null - ? (param as { type?: string }).type || 'any' - : 'any' - const paramDesc = - typeof param === 'object' && param !== null - ? (param as { description?: string }).description - : undefined - - return ( -
-
- - {paramName} - - - {paramType} - - {isRequired && ( - - required - - )} -
- {paramDesc && ( -

- {paramDesc} -

- )} -
- ) - } - )} -
-
- )} +
- ) - })} -
- )} - -
-
+ )} +
+ ) + })} +
+ )} +
-
+ ) } @@ -613,14 +606,15 @@ export function MCP() { placeholder: 'Search MCPs...', }} actions={ - setShowAddModal(true)} - disabled={serversLoading} - > - Add Server - + [ + { + text: 'Add server', + icon: Plus, + variant: 'primary', + onSelect: () => setShowAddModal(true), + disabled: serversLoading, + }, + ] satisfies SettingsAction[] } > {error ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts new file mode 100644 index 00000000000..2afdf280f2c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts @@ -0,0 +1,33 @@ +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' + +interface SaveDiscardActionsConfig { + dirty: boolean + saving: boolean + onSave: () => void + onDiscard: () => void + saveDisabled?: boolean + savingLabel?: string + saveLabel?: string +} + +/** The dirty-gated Discard + Save action pair for settings surfaces — empty when not dirty. */ +export function saveDiscardActions({ + dirty, + saving, + onSave, + onDiscard, + saveDisabled = false, + savingLabel = 'Saving...', + saveLabel = 'Save', +}: SaveDiscardActionsConfig): SettingsAction[] { + if (!dirty) return [] + return [ + { text: 'Discard', onSelect: onDiscard, disabled: saving }, + { + text: saving ? savingLabel : saveLabel, + variant: 'primary', + onSelect: onSave, + disabled: saving || saveDisabled, + }, + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx deleted file mode 100644 index e65657017f4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Chip } from '@sim/emcn' - -interface SaveDiscardActionsProps { - /** When false, renders nothing. */ - dirty: boolean - /** A save is in flight — disables both chips and shows `savingLabel` on Save. */ - saving: boolean - onSave: () => void - onDiscard: () => void - /** Disables Save independently of `saving` (e.g. validation errors, empty required field). */ - saveDisabled?: boolean - savingLabel?: string - saveLabel?: string -} - -/** - * The canonical dirty-gated Discard + Save chip pair for settings surfaces. - * Renders nothing when not `dirty`; otherwise a fragment (no wrapper) so it - * composes beside sibling chips in a `SettingsPanel` actions slot or a detail - * header bar (e.g. group-detail's Delete, data-retention's Remove override). - */ -export function SaveDiscardActions({ - dirty, - saving, - onSave, - onDiscard, - saveDisabled = false, - savingLabel = 'Saving...', - saveLabel = 'Save', -}: SaveDiscardActionsProps) { - if (!dirty) return null - return ( - <> - - Discard - - - {saving ? savingLabel : saveLabel} - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 13122205697..cbd687df538 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -17,6 +17,7 @@ import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/cr import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' @@ -942,32 +943,42 @@ export function SecretsManager() { onChange: setSearchTerm, placeholder: 'Search secrets...', }} - actions={ - <> - {hasChanges && ( - - Discard - - )} - {hasConflicts || hasInvalidKeys ? ( - - -
- Save -
-
- {hasConflicts ? ( - Resolve all conflicts before saving - ) : ( - Fix invalid variable names before saving - )} -
- ) : ( - - {isListSaving ? 'Saving...' : 'Save'} - - )} - + actions={[ + ...(hasChanges + ? [ + { + text: 'Discard', + onSelect: handleCancel, + disabled: isListSaving, + } satisfies SettingsAction, + ] + : []), + ...(hasConflicts || hasInvalidKeys + ? [] + : [ + { + text: isListSaving ? 'Saving...' : 'Save', + variant: 'primary', + onSelect: handleSave, + disabled: isLoading || !hasChanges || isListSaving, + } satisfies SettingsAction, + ]), + ]} + aside={ + hasConflicts || hasInvalidKeys ? ( + + +
+ Save +
+
+ {hasConflicts ? ( + Resolve all conflicts before saving + ) : ( + Fix invalid variable names before saving + )} +
+ ) : undefined } > {!isLoading && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx new file mode 100644 index 00000000000..5d79de25809 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx @@ -0,0 +1,184 @@ +'use client' + +import { + type ComponentType, + createContext, + type ReactNode, + type Ref, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Chip, ChipInput, ChipLink, Search } from '@sim/emcn' + +/** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */ +export interface SettingsAction { + text: string + icon?: ComponentType<{ className?: string }> + variant?: 'primary' | 'destructive' + active?: boolean + onSelect: () => void + disabled?: boolean +} + +export interface SettingsHeaderSearch { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean +} + +/** Left-aligned back chip for a detail sub-view, returning to the section's list. */ +export interface SettingsBackAction { + text: string + icon?: ComponentType<{ className?: string }> + onSelect: () => void +} + +export interface SettingsHeaderConfig { + title?: string + description?: string + docsLink?: string + back?: SettingsBackAction + actions?: SettingsAction[] + search?: SettingsHeaderSearch + /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */ + scrollContainerRef?: Ref + /** Escape hatch for a right-aligned widget that genuinely cannot be a chip. */ + aside?: ReactNode +} + +const EMPTY_CONFIG: SettingsHeaderConfig = {} + +const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null) + +interface ReadContextValue { + configRef: { current: SettingsHeaderConfig } + signature: string +} + +const ReadContext = createContext(null) + +/** Visible/structural fields only — callbacks stay in the ref, so registering never loops or serves a stale handler. */ +function computeSignature(c: SettingsHeaderConfig): string { + return JSON.stringify({ + t: c.title ?? '', + d: c.description ?? '', + k: c.docsLink ?? '', + b: c.back ? [c.back.text, c.back.icon ? 1 : 0] : null, + a: c.actions?.map((x) => [ + x.text, + x.variant ?? '', + x.active ?? false, + x.disabled ?? false, + x.icon ? 1 : 0, + ]), + s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null, + aside: c.aside ? 1 : 0, + }) +} + +export function SettingsHeaderProvider({ children }: { children: ReactNode }) { + const configRef = useRef(EMPTY_CONFIG) + const [signature, setSignature] = useState('') + + const register = useCallback((config: SettingsHeaderConfig) => { + configRef.current = config + const next = computeSignature(config) + setSignature((prev) => (prev === next ? prev : next)) + }, []) + + const readValue = useMemo(() => ({ configRef, signature }), [signature]) + + return ( + + {children} + + ) +} + +/** Registers a section's header content into the persistent settings chrome. */ +export function useSettingsHeader(config: SettingsHeaderConfig) { + const register = useContext(RegisterContext) + + useEffect(() => { + register?.(config) + }) + + useEffect(() => { + return () => register?.(EMPTY_CONFIG) + }, [register]) +} + +/** + * The single owner of settings page chrome: the header bar (back chip, Docs link, + * action chips, `aside`), the scroll region, and the centered column led by the + * title + description, then search and `{children}`. + */ +export function SettingsHeaderShell({ children }: { children: ReactNode }) { + const read = useContext(ReadContext) + const config = read?.configRef.current ?? EMPTY_CONFIG + const { title, description, docsLink, back, actions, search, aside, scrollContainerRef } = config + + return ( +
+
+ {back ? ( + + {back.text} + + ) : ( +
+ )} +
+ {docsLink && ( + + Docs + + )} + {aside} + {actions?.map((action) => ( + + {action.text} + + ))} +
+
+
+
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + {search && ( + search.onChange(event.target.value)} + disabled={search.disabled} + autoComplete='off' + className='w-full' + /> + )} + {children} +
+
+
+ ) +} 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..cc67b963690 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 + /** 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). */ + /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */ 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). - */ - search?: SettingsPanelSearch + /** Escape hatch for a right-aligned widget that genuinely cannot be a chip. Rare. */ + aside?: ReactNode + /** 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 `
` or a padding change. */ export function SettingsPanel({ children, actions, + back, + search, title, description, docsLink, - contentClassName, + aside, scrollContainerRef, - search, }: SettingsPanelProps) { const section = useSettingsSection() const meta = section ? getSettingsSectionMeta(section) : null - const resolvedTitle = title ?? meta?.label - const resolvedDescription = description ?? meta?.description - const resolvedDocsLink = docsLink ?? meta?.docsLink - return ( -
-
-
-
- {resolvedDocsLink && ( - - Docs - - )} - {actions} -
-
-
-
- {(resolvedTitle || resolvedDescription) && ( -
- {resolvedTitle && ( -

{resolvedTitle}

- )} - {resolvedDescription && ( -

{resolvedDescription}

- )} -
- )} - {search && ( - search.onChange(event.target.value)} - disabled={search.disabled} - autoComplete='off' - className='w-full' - /> - )} - {children} -
-
-
- ) + useSettingsHeader({ + title: title ?? meta?.label, + description: description ?? meta?.description, + docsLink: docsLink ?? meta?.docsLink, + back, + actions, + search, + aside, + scrollContainerRef, + }) + + return <>{children} } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 31d968cc279..dfdd71bb9d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { Chip, Plus } from '@sim/emcn' +import { Plus } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' @@ -313,17 +313,15 @@ export function TeamManagement() { return ( <> setInviteModalOpen(true)} - disabled={isInvitationsDisabled} - title={isInvitationsDisabled ? 'Invitations are disabled' : undefined} - > - Invite - - } + actions={[ + { + text: 'Invite', + icon: Plus, + variant: 'primary', + onSelect: () => setInviteModalOpen(true), + disabled: isInvitationsDisabled, + }, + ]} > -
- - MCP Servers - -
-
+ + {null} + ) } if (error || !data) { return ( -
-
- - MCP Servers - -
+

Failed to load server details

-
+ ) } const { server } = data - const detailHeaderJsx = ( -
- - MCP Servers - -
- Edit Server - {showAddDisabledTooltip ? ( - - -
- - Add Workflows - -
-
- - All deployed workflows have been added to this server. - -
- ) : ( - setShowAddWorkflow(true)} - disabled={!canAddWorkflow} - > - Add Workflows + const addWorkflowsAside = showAddDisabledTooltip ? ( + + +
+ + Add workflows - )} -
-
+
+ + All deployed workflows have been added to this server. + + ) : ( + setShowAddWorkflow(true)} + disabled={!canAddWorkflow} + > + Add workflows + ) return ( <> -
- {detailHeaderJsx} -
-
-
- setActiveServerTab(value as 'workflows' | 'details')} - /> - -
- {activeServerTab === 'workflows' && ( -
- - Workflows - + +
+ setActiveServerTab(value as 'workflows' | 'details')} + /> - {tools.length === 0 ? ( -

- No workflows added yet. Click "Add Workflow" to add a deployed - workflow. -

- ) : ( -
- {tools.map((tool) => ( -
-
- - {tool.toolName} - -

- {tool.toolDescription || 'No description'} -

-
-
- setToolToView(tool) }, - { - label: 'Remove', - destructive: true, - disabled: deleteToolMutation.isPending, - onSelect: () => setToolToDelete(tool), - }, - ]} - /> -
-
- ))} +
+ {activeServerTab === 'workflows' && ( +
+ Workflows + + {tools.length === 0 ? ( +

+ No workflows added yet. Click "Add Workflow" to add a deployed + workflow. +

+ ) : ( +
+ {tools.map((tool) => ( +
+
+ + {tool.toolName} + +

+ {tool.toolDescription || 'No description'} +

+
+
+ setToolToView(tool) }, + { + label: 'Remove', + destructive: true, + disabled: deleteToolMutation.isPending, + onSelect: () => setToolToDelete(tool), + }, + ]} + /> +
- )} + ))} +
+ )} - {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

- Deploy a workflow first to add it to this server. -

- )} + {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( +

+ Deploy a workflow first to add it to this server. +

+ )} +
+ )} + + {activeServerTab === 'details' && ( +
+
+
+ + Server Name + +

{server.name}

+
+
+ + Transport + +

Streamable-HTTP

+
+
+ Access +

+ {server.isPublic ? 'Public' : 'API Key'} +

+
+
+ + {server.description?.trim() && ( +
+ + Description + +

{server.description}

)} - {activeServerTab === 'details' && ( -
-
-
- - Server Name - -

{server.name}

-
-
- - Transport - -

Streamable-HTTP

-
-
- - Access - -

- {server.isPublic ? 'Public' : 'API Key'} -

-
-
+
+ URL +

{mcpServerUrl}

+
- {server.description?.trim() && ( -
- - Description - -

- {server.description} -

-
- )} +
+
+ + MCP Client + +
+ setActiveConfigTab(v as McpClientType)} + > + Cursor + Claude Code + Claude Desktop + VS Code + Sim + +
-
- URL -

- {mcpServerUrl} + {activeConfigTab === 'sim' ? ( +

+
+

+ Add this MCP server to your workspace so you can use its tools in other + workflows via the MCP block.

+ + {addToWorkspaceMutation.isError && ( +

+ {addToWorkspaceMutation.error?.message || 'Failed to add server'} +

+ )}
- -
-
- - MCP Client - -
- setActiveConfigTab(v as McpClientType)} +
+ ) : ( +
+
+ + Configuration + +
- - {activeConfigTab === 'sim' ? ( -
-
-

- Add this MCP server to your workspace so you can use its tools in other - workflows via the MCP block. -

- - {addToWorkspaceMutation.isError && ( -

- {addToWorkspaceMutation.error?.message || 'Failed to add server'} -

- )} -
-
- ) : ( -
-
- - Configuration - - -
-
- + + {activeConfigTab === 'cursor' && ( + + Add to Cursor - {activeConfigTab === 'cursor' && ( - - Add to Cursor - - )} -
- {!server.isPublic && ( -

- Replace $SIM_API_KEY with your API key, or{' '} - -

- )} -
+ + )} +
+ {!server.isPublic && ( +

+ Replace $SIM_API_KEY with your API key, or{' '} + +

)}
)}
-
+ )}
-
+ setShowAddModal(true), + disabled: isLoading, + }, + ] + return ( <> setShowAddModal(true)} - disabled={isLoading} - > - Add Server - - } + actions={actions} >
{error ? ( diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 99211cc7edd..c01a1a8f30a 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from 'react' import { Checkbox, - Chip, ChipModal, ChipModalBody, ChipModalError, @@ -164,16 +163,19 @@ export function AccessControl() { onChange: setSearchTerm, placeholder: 'Search permission groups...', }} - actions={ - setShowCreateModal(true)}> - Create Group - - } + actions={[ + { + text: 'Create group', + icon: Plus, + variant: 'primary', + onSelect: () => setShowCreateModal(true), + }, + ]} > {permissionGroups.length === 0 ? ( - No permission groups yet. Click "Create Group" to get started. + No permission groups yet. Click "Create group" to get started. ) : filteredGroups.length === 0 ? ( diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 4c712e60bfc..782e3c48bad 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -35,7 +35,8 @@ import { MemberRow, } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SaveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { getAllBlocks } from '@/blocks' @@ -1254,388 +1255,366 @@ export function GroupDetail({ return ( <> -
-
- - Access Control - -
- - setShowDeleteConfirm(true)} - disabled={deletePermissionGroup.isPending} - > - {deletePermissionGroup.isPending ? 'Deleting...' : 'Delete'} - -
-
- -
-
- setConfigTab(value as ConfigTab)} - /> -
-
+ setShowDeleteConfirm(true), + disabled: deletePermissionGroup.isPending, + }, + ]} + > + setConfigTab(value as ConfigTab)} + /> -
-
-
-

{viewingGroup.name}

- {viewingGroup.description && ( -

{viewingGroup.description}

+ {configTab === 'general' && ( + <> + +
+ + 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 + +
+ ) : ( +
+
+ + {viewingGroup.workspaces.length > 0 + ? `Governs ${viewingGroup.workspaces.length} workspace${ + viewingGroup.workspaces.length === 1 ? '' : 's' + }` + : 'Select the workspaces this group governs'} + + ws.id)} + onChange={handleScopeChange} + options={workspaceOptions} + isLoading={workspacesLoading} + allowAllWorkspaces={false} + className='flex-shrink-0' + /> +
+ {viewingGroup.workspaces.length > 0 && ( +
+ {viewingGroup.workspaces.map((ws) => ( + + ))} +
+ )} +
)} -
+ - {configTab === 'general' && ( - <> - + {!viewingGroup.isDefault && ( + +
- 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 ? ( +
+ {[1, 2].map((i) => ( +
+ + +
+ ))}
) : ( -
-
- - {viewingGroup.workspaces.length > 0 - ? `Governs ${viewingGroup.workspaces.length} workspace${ - viewingGroup.workspaces.length === 1 ? '' : 's' - }` - : 'Select the workspaces this group governs'} - - ws.id)} - onChange={handleScopeChange} - options={workspaceOptions} - isLoading={workspacesLoading} - allowAllWorkspaces={false} - className='flex-shrink-0' - /> + members.length > 0 && ( +
+ {members.map((member) => ( + handleRemoveMember(member.id), + destructive: true, + }, + ]} + /> + } + /> + ))}
- {viewingGroup.workspaces.length > 0 && ( -
- {viewingGroup.workspaces.map((ws) => ( - - ))} -
- )} -
+ ) )} - - - {!viewingGroup.isDefault && ( - -
-
- - {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 - -
- {membersLoading ? ( -
- {[1, 2].map((i) => ( -
- - -
- ))} -
- ) : ( - members.length > 0 && ( -
- {members.map((member) => ( - handleRemoveMember(member.id), - destructive: true, - }, - ]} - /> - } - /> - ))} -
- ) - )} -
-
- )} - +
+ )} + + )} - {configTab === 'providers' && ( -
-
- setProviderSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> + {configTab === 'providers' && ( +
+
+ setProviderSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} + > + {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} + +
+
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+
+ )} + + {configTab === 'blocks' && ( +
+
+ setIntegrationSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> +
+ {filteredCoreBlocks.length > 0 && ( + - setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed) - } + flush + onClick={() => setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)} > - {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} + {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'} + } + > +
+ {filteredCoreBlocks.map((block) => { + const BlockIcon = block.icon + const checkboxId = `block-${block.type}` + return ( + + ) + })}
+
+ )} + {filteredToolBlocks.length > 0 && ( + + Allow a whole integration with its checkbox, then expand it to deny specific + tools while keeping the rest available. + + } + action={ + setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)} + > + {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'} + + } + >
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} + {filteredToolBlocks.map((block) => ( + toggleIntegration(block.type)} + deniedCount={deniedCountByBlock[block.type] ?? 0} + isAllowed={isToolAllowed} + onToggle={toggleTool} + onSetDenied={setToolsDenied} /> ))}
-
+ )} +
+ )} - {configTab === 'blocks' && ( -
-
- setIntegrationSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> -
- {filteredCoreBlocks.length > 0 && ( - setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)} - > - {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'} - - } - > -
- {filteredCoreBlocks.map((block) => { - const BlockIcon = block.icon - const checkboxId = `block-${block.type}` - return ( - - ) - })} -
-
- )} - {filteredToolBlocks.length > 0 && ( - - Allow a whole integration with its checkbox, then expand it to deny specific - tools while keeping the rest available. - - } - action={ - setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)} + {configTab === 'platform' && ( +
+
+ setPlatformSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + + setEditingConfig((prev) => ({ + ...prev, + ...Object.fromEntries( + filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible]) + ), + })) + } + > + {platformAllVisible ? 'Deselect All' : 'Select All'} + +
+ {platformCategorySections.map(({ category, features }) => ( + +
+ {features.map((feature) => ( +
+
- )} - - {configTab === 'platform' && ( -
-
- setPlatformSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - + ))} +
+ + ))} + +
+ +
+ + Auth modes public file-share links may use + +
- {platformCategorySections.map(({ category, features }) => ( - -
- {features.map((feature) => ( -
- - {feature.hint} -
- ))} -
-
- ))} - -
- -
- - Auth modes public file-share links may use - - -
-
-
- )} +
-
-
+ )} + setCreateOpen(true)}> - New Drain -
- } + actions={[ + { + text: 'New drain', + icon: Plus, + variant: 'primary', + onSelect: () => setCreateOpen(true), + }, + ]} search={{ value: searchTerm, onChange: setSearchTerm, @@ -182,7 +184,7 @@ export function DataDrainsSettings() { ) ) : ( - Click "New Drain" above to get started + Click "New drain" above to get started )}
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index af4daade496..c716319c60c 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -30,8 +30,9 @@ import { } from '@/lib/guardrails/pii-entities' import { getUserRole } from '@/lib/workspaces/organization/utils' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' -import { SaveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -321,123 +322,117 @@ function PolicyDetail({ : 'Overrides the organization defaults for the selected workspaces.' return ( -
-
- - Data retention - -
- - {canRemove && ( - - Remove override - - )} -
-
+ + {!isOrg && ( + +
+ + {draft.workspaceIds.length > 0 + ? `Overrides ${draft.workspaceIds.length} workspace${draft.workspaceIds.length === 1 ? '' : 's'}` + : 'Select the workspaces this override applies to'} + + onChange({ ...draft, workspaceIds })} + options={workspaceOptions} + placeholder='Select workspaces' + className='flex-shrink-0' + /> +
+
+ )} -
-
-
-

{title}

-

{description}

+ +
+
+ Log retention + onChange({ ...draft, logDays })} + /> +
+
+ Soft deletion cleanup + onChange({ ...draft, softDeleteDays })} + />
+
+ Task cleanup + onChange({ ...draft, taskCleanupDays })} + /> +
+
+
- {!isOrg && ( - + {piiEnabled && ( + +
+ {!isOrg && (
- - {draft.workspaceIds.length > 0 - ? `Overrides ${draft.workspaceIds.length} workspace${draft.workspaceIds.length === 1 ? '' : 's'}` - : 'Select the workspaces this override applies to'} + + Inherit the organization defaults or set workspace-specific redaction - onChange({ ...draft, workspaceIds })} - options={workspaceOptions} - placeholder='Select workspaces' - className='flex-shrink-0' + onChange({ ...draft, piiOverride: mode === 'override' })} + aria-label='PII redaction override mode' + options={[ + { value: 'inherit', label: 'Inherit' }, + { value: 'override', label: 'Override' }, + ]} />
- - )} - - -
-
- Log retention - onChange({ ...draft, logDays })} - /> -
-
- Soft deletion cleanup - onChange({ ...draft, softDeleteDays })} - /> -
-
- Task cleanup - onChange({ ...draft, taskCleanupDays })} + )} + {showPiiGrid && ( + <> + onChange({ ...draft, piiEntityTypes })} /> -
-
-
- - {piiEnabled && ( - -
- {!isOrg && ( -
- - Inherit the organization defaults or set workspace-specific redaction - - onChange({ ...draft, piiOverride: mode === 'override' })} - aria-label='PII redaction override mode' - options={[ - { value: 'inherit', label: 'Inherit' }, - { value: 'override', label: 'Override' }, - ]} - /> -
- )} - {showPiiGrid && ( - <> - onChange({ ...draft, piiEntityTypes })} - /> -
- Language - onChange({ ...draft, piiLanguage })} - /> -
- - )} -
-
- )} -
-
-
+
+ Language + onChange({ ...draft, piiLanguage })} + /> +
+ + )} +
+ + )} +
) } @@ -780,16 +775,15 @@ export function DataRetentionSettings() { /> ) : ( - Add override - - } + actions={[ + { + text: 'Add override', + icon: Plus, + variant: 'primary', + onSelect: openAddOverride, + disabled: freeWorkspaces.length === 0, + }, + ]} >
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 457a161b0ea..4a6f8e4a4c4 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -3,7 +3,6 @@ import { type ReactNode, useState } from 'react' import { Button, - Chip, ChipCombobox, ChipInput, ChipSelect, @@ -24,8 +23,9 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' -import { SaveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' @@ -449,13 +449,7 @@ export function SSO() { const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}` return ( - - Edit - - } - > +

{existingProvider.providerId}

@@ -507,7 +501,7 @@ export function SSO() { } return ( -
+ - {isEditing && !hasChanges && ( - - Cancel - - )} - void handleSubmit()} - onDiscard={handleDiscard} - /> - - } + actions={[ + ...(isEditing && !hasChanges + ? [ + { + text: 'Cancel', + onSelect: handleDiscard, + disabled: configureSSOMutation.isPending, + } satisfies SettingsAction, + ] + : []), + ...saveDiscardActions({ + dirty: hasChanges, + saving: configureSSOMutation.isPending, + saveDisabled: hasAnyErrors(errors) || !isFormValid(), + saveLabel: isEditing ? 'Update' : 'Save', + savingLabel: isEditing ? 'Updating...' : 'Saving...', + onSave: () => void handleSubmit(), + onDiscard: handleDiscard, + }), + ]} >
diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index ae2c82d1296..04f34a50472 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -17,7 +17,7 @@ import { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL, } from '@/app/workspace/[workspaceId]/components/credential-detail' -import { SaveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -328,15 +328,13 @@ export function WhitelabelingSettings() { return ( - } + actions={saveDiscardActions({ + dirty: hasChanges, + saving: updateSettings.isPending, + saveDisabled: isUploading, + onSave: handleSave, + onDiscard: handleDiscard, + })} >
From a61b2ec071260af819807c876893da36e6256116 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 20:14:17 -0700 Subject: [PATCH 2/6] =?UTF-8?q?refactor(settings):=20cleanup=20pass=20?= =?UTF-8?q?=E2=80=94=20deref=20handlers,=20tooltip=20data=20field,=20parit?= =?UTF-8?q?y=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address /cleanup + /simplify + per-page parity audit findings: - Foundation: shell dereferences action/back/search handlers from the config ref at call time, so a section re-render that changes a closure (e.g. a dirty-form onSave) can never leave the shell holding a stale handler. - Add SettingsAction.tooltip (shell renders the hover tooltip, incl. for disabled actions). Migrate the secrets-manager + workflow-mcp 'aside' tooltips to data actions — eliminates the per-page
-wrapped tooltip chips (Emir's no-div). - Parity: detail-view description no longer falls back to the section meta blurb (empty-description groups render blank again); secrets Save keeps no variant. - Sentence-case: group-detail back 'Access control'; empty-state prose 'Create API key' / 'Add tool' / 'Add server'. - Drop redundant array-level 'satisfies SettingsAction[]'; make SettingsPanel children optional (loading detail states drop the {null}). --- .../settings/components/api-keys/api-keys.tsx | 2 +- .../settings/components/copilot/copilot.tsx | 2 +- .../credential-sets/credential-sets.tsx | 5 +- .../components/custom-tools/custom-tools.tsx | 2 +- .../settings/components/mcp/mcp.tsx | 47 ++++++++-------- .../secrets-manager/secrets-manager.tsx | 38 ++++--------- .../settings-header/settings-header.tsx | 54 +++++++++++++------ .../settings-panel/settings-panel.tsx | 10 ++-- .../workflow-mcp-servers.tsx | 45 +++++----------- .../components/group-detail.tsx | 2 +- 10 files changed, 96 insertions(+), 111 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 571911f3630..1fefe86ba36 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -157,7 +157,7 @@ export function ApiKeys() { actions={actions} > {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( - Click "Create API Key" above to get started + Click "Create API key" above to get started ) : (
{!searchTerm.trim() ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index c29abab3127..b02be972520 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -128,7 +128,7 @@ export function Copilot() { actions={actions} > {isLoading ? null : showEmptyState ? ( - Click "Create API Key" above to get started + Click "Create API key" above to get started ) : (
{filteredKeys.map((key) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index d6a07ce04bf..acdcc5eb116 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -33,7 +33,6 @@ import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { @@ -608,14 +607,14 @@ export function CredentialSets() { }} actions={ canManageCredentialSets - ? ([ + ? [ { text: 'Create group', icon: Plus, variant: 'primary', onSelect: () => setShowCreateModal(true), }, - ] satisfies SettingsAction[]) + ] : undefined } > diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 3d683fd3dfe..a537708b167 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -115,7 +115,7 @@ export function CustomTools() {

) : isLoading ? null : showEmptyState ? ( - Click "Add Tool" above to get started + Click "Add tool" above to get started ) : (
{filteredTools.map((tool) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 358a2f0bcb5..626eb0900d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -23,7 +23,6 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' @@ -388,19 +387,17 @@ export function MCP() { handleRefreshServer(server.id), - disabled: refreshingServerId === server.id || refreshedServerId === server.id, - }, - { - text: 'Edit', - onSelect: () => setEditingServerId(server.id), - }, - ] satisfies SettingsAction[] - } + actions={[ + { + text: refreshLabel, + onSelect: () => handleRefreshServer(server.id), + disabled: refreshingServerId === server.id || refreshedServerId === server.id, + }, + { + text: 'Edit', + onSelect: () => setEditingServerId(server.id), + }, + ]} >
@@ -605,17 +602,15 @@ export function MCP() { onChange: setSearchTerm, placeholder: 'Search MCPs...', }} - actions={ - [ - { - text: 'Add server', - icon: Plus, - variant: 'primary', - onSelect: () => setShowAddModal(true), - disabled: serversLoading, - }, - ] satisfies SettingsAction[] - } + actions={[ + { + text: 'Add server', + icon: Plus, + variant: 'primary', + onSelect: () => setShowAddModal(true), + disabled: serversLoading, + }, + ]} > {error ? (
@@ -624,7 +619,7 @@ export function MCP() {

) : serversLoading ? null : !hasServers ? ( - Click "Add Server" above to get started + Click "Add server" above to get started ) : (
{filteredServers.map((server) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index cbd687df538..84b226c14ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Chip, ChipInput, cn, Tooltip, toast } from '@sim/emcn' +import { ChipInput, cn, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { useQueryClient } from '@tanstack/react-query' @@ -953,33 +953,17 @@ export function SecretsManager() { } satisfies SettingsAction, ] : []), - ...(hasConflicts || hasInvalidKeys - ? [] - : [ - { - text: isListSaving ? 'Saving...' : 'Save', - variant: 'primary', - onSelect: handleSave, - disabled: isLoading || !hasChanges || isListSaving, - } satisfies SettingsAction, - ]), + { + text: isListSaving ? 'Saving...' : 'Save', + onSelect: handleSave, + disabled: hasConflicts || hasInvalidKeys || isLoading || !hasChanges || isListSaving, + tooltip: hasConflicts + ? 'Resolve all conflicts before saving' + : hasInvalidKeys + ? 'Fix invalid variable names before saving' + : undefined, + }, ]} - aside={ - hasConflicts || hasInvalidKeys ? ( - - -
- Save -
-
- {hasConflicts ? ( - Resolve all conflicts before saving - ) : ( - Fix invalid variable names before saving - )} -
- ) : undefined - } > {!isLoading && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx index 5d79de25809..ae05b6c67f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx @@ -3,6 +3,7 @@ import { type ComponentType, createContext, + Fragment, type ReactNode, type Ref, useCallback, @@ -12,7 +13,7 @@ import { useRef, useState, } from 'react' -import { Chip, ChipInput, ChipLink, Search } from '@sim/emcn' +import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn' /** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */ export interface SettingsAction { @@ -22,6 +23,8 @@ export interface SettingsAction { active?: boolean onSelect: () => void disabled?: boolean + /** Hover/focus tooltip (e.g. why the action is disabled) — the shell renders it; no per-page JSX. */ + tooltip?: string } export interface SettingsHeaderSearch { @@ -62,7 +65,11 @@ interface ReadContextValue { const ReadContext = createContext(null) -/** Visible/structural fields only — callbacks stay in the ref, so registering never loops or serves a stale handler. */ +/** + * Serializes only the visible/structural fields — callbacks stay in the ref and + * the shell dereferences them at call time, so registering never loops and never + * serves a stale handler. Any new VISIBLE field must be added here too. + */ function computeSignature(c: SettingsHeaderConfig): string { return JSON.stringify({ t: c.title ?? '', @@ -75,6 +82,7 @@ function computeSignature(c: SettingsHeaderConfig): string { x.active ?? false, x.disabled ?? false, x.icon ? 1 : 0, + x.tooltip ?? '', ]), s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null, aside: c.aside ? 1 : 0, @@ -120,14 +128,15 @@ export function useSettingsHeader(config: SettingsHeaderConfig) { */ export function SettingsHeaderShell({ children }: { children: ReactNode }) { const read = useContext(ReadContext) - const config = read?.configRef.current ?? EMPTY_CONFIG + const configRef = read?.configRef + const config = configRef?.current ?? EMPTY_CONFIG const { title, description, docsLink, back, actions, search, aside, scrollContainerRef } = config return (
{back ? ( - + configRef?.current.back?.onSelect()}> {back.text} ) : ( @@ -140,18 +149,29 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { )} {aside} - {actions?.map((action) => ( - - {action.text} - - ))} + {actions?.map((action, index) => { + const chip = ( + configRef?.current.actions?.[index]?.onSelect()} + disabled={action.disabled} + > + {action.text} + + ) + return action.tooltip ? ( + + + {chip} + + {action.tooltip} + + ) : ( + {chip} + ) + })}
search.onChange(event.target.value)} + onChange={(event) => configRef?.current.search?.onChange(event.target.value)} disabled={search.disabled} autoComplete='off' className='w-full' 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 cc67b963690..b6d95c30240 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 @@ -37,7 +37,7 @@ function useSettingsSection(): SettingsSection | null { interface SettingsPanelProps { /** Body content rendered below the header in the centered content column. */ - children: 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. */ @@ -46,7 +46,11 @@ interface SettingsPanelProps { search?: SettingsHeaderSearch /** Overrides the nav-driven title (e.g. for a detail sub-view). */ title?: string - /** Overrides the nav-driven description. */ + /** + * 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. + */ description?: string /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */ docsLink?: string @@ -80,7 +84,7 @@ export function SettingsPanel({ useSettingsHeader({ title: title ?? meta?.label, - description: description ?? meta?.description, + description: title !== undefined ? description : (description ?? meta?.description), docsLink: docsLink ?? meta?.docsLink, back, actions, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 4c60795aff1..8b789de1b1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -6,7 +6,6 @@ import { Button, ButtonGroup, ButtonGroupItem, - Chip, ChipConfirmModal, ChipInput, ChipModal, @@ -20,7 +19,6 @@ import { Code, type ComboboxOption, Label, - Tooltip, } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -355,11 +353,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro ) if (isLoading) { - return ( - - {null} - - ) + return } if (error || !data) { @@ -376,35 +370,24 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro const { server } = data - const addWorkflowsAside = showAddDisabledTooltip ? ( - - -
- - Add workflows - -
-
- All deployed workflows have been added to this server. -
- ) : ( - setShowAddWorkflow(true)} - disabled={!canAddWorkflow} - > - Add workflows - - ) - return ( <> setShowAddWorkflow(true), + disabled: !canAddWorkflow, + tooltip: showAddDisabledTooltip + ? 'All deployed workflows have been added to this server.' + : undefined, + }, + { text: 'Edit server', onSelect: handleOpenEditServer }, + ]} >
Date: Mon, 29 Jun 2026 20:16:20 -0700 Subject: [PATCH 3/6] docs(settings): document SettingsAction.tooltip in the settings rule --- .claude/rules/sim-settings-pages.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index b479f6d4ae2..f2e7a5f63b5 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -55,14 +55,16 @@ return ( ## `SettingsPanel` props - `actions?: SettingsAction[]` — right-aligned header chips, **data only**: - `{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled? }`. + `{ 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`). Save/Discard pairs come from the `saveDiscardActions()` - helper (spread it into `actions`). A widget that genuinely cannot be a chip - (tooltip-wrapped chip, custom dropdown) goes in the `aside` escape hatch. + 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 From 8bb7f5483523f45dc92cf8557bfa29c8718227b1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 20:22:13 -0700 Subject: [PATCH 4/6] fix(settings): add key to header action chip (biome useJsxKeyInIterable) --- .../settings/components/settings-header/settings-header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx index ae05b6c67f7..3a4e1f29207 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx @@ -3,7 +3,6 @@ import { type ComponentType, createContext, - Fragment, type ReactNode, type Ref, useCallback, @@ -152,6 +151,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { {actions?.map((action, index) => { const chip = ( {action.tooltip} ) : ( - {chip} + chip ) })}
From d56c0a296374c7dbbbecca54f4565d39bb328760 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 20:30:22 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(settings):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20aside=20footgun,=20sticky=20tabs,=20restor?= =?UTF-8?q?e=20tooltips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the SettingsHeaderConfig 'aside' escape hatch (the last stale-prone, div-admitting path). Add SettingsAction.onPrefetch so the teammates Invite chip (hover-prefetch) is pure data — nothing renders header JSX now, so the signature-vs-ref staleness cursor flagged is gone. - useIsomorphicLayoutEffect for header registration: section switches flush the new header before paint (no stale/blank header frame). - group-detail: pin the config tabs (sticky top-0) so they stay visible while the detail body scrolls. - Restore the team-management Invite disabled-reason tooltip via the new SettingsAction.tooltip field. --- .../settings-header/settings-header.tsx | 31 +++++++++++++------ .../settings-panel/settings-panel.tsx | 4 --- .../team-management/team-management.tsx | 1 + .../components/teammates/teammates.tsx | 24 +++++++------- .../components/group-detail.tsx | 12 ++++--- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx index 3a4e1f29207..14a200d89fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx @@ -8,12 +8,16 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react' import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn' +/** `useLayoutEffect` on the client (flush header changes before paint), `useEffect` during SSR. */ +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect + /** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */ export interface SettingsAction { text: string @@ -24,6 +28,8 @@ export interface SettingsAction { disabled?: boolean /** Hover/focus tooltip (e.g. why the action is disabled) — the shell renders it; no per-page JSX. */ tooltip?: string + /** Warm a lazy resource on hover/focus (e.g. prefetch the upgrade flow). */ + onPrefetch?: () => void } export interface SettingsHeaderSearch { @@ -49,8 +55,6 @@ export interface SettingsHeaderConfig { search?: SettingsHeaderSearch /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */ scrollContainerRef?: Ref - /** Escape hatch for a right-aligned widget that genuinely cannot be a chip. */ - aside?: ReactNode } const EMPTY_CONFIG: SettingsHeaderConfig = {} @@ -82,9 +86,9 @@ function computeSignature(c: SettingsHeaderConfig): string { x.disabled ?? false, x.icon ? 1 : 0, x.tooltip ?? '', + x.onPrefetch ? 1 : 0, ]), s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null, - aside: c.aside ? 1 : 0, }) } @@ -111,25 +115,25 @@ export function SettingsHeaderProvider({ children }: { children: ReactNode }) { export function useSettingsHeader(config: SettingsHeaderConfig) { const register = useContext(RegisterContext) - useEffect(() => { + useIsomorphicLayoutEffect(() => { register?.(config) }) - useEffect(() => { + useIsomorphicLayoutEffect(() => { return () => register?.(EMPTY_CONFIG) }, [register]) } /** * The single owner of settings page chrome: the header bar (back chip, Docs link, - * action chips, `aside`), the scroll region, and the centered column led by the - * title + description, then search and `{children}`. + * action chips), the scroll region, and the centered column led by the title + + * description, then search and `{children}`. */ export function SettingsHeaderShell({ children }: { children: ReactNode }) { const read = useContext(ReadContext) const configRef = read?.configRef const config = configRef?.current ?? EMPTY_CONFIG - const { title, description, docsLink, back, actions, search, aside, scrollContainerRef } = config + const { title, description, docsLink, back, actions, search, scrollContainerRef } = config return (
@@ -147,7 +151,6 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { Docs )} - {aside} {actions?.map((action, index) => { const chip = ( configRef?.current.actions?.[index]?.onSelect()} + onMouseEnter={ + action.onPrefetch + ? () => configRef?.current.actions?.[index]?.onPrefetch?.() + : undefined + } + onFocus={ + action.onPrefetch + ? () => configRef?.current.actions?.[index]?.onPrefetch?.() + : undefined + } disabled={action.disabled} > {action.text} 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 b6d95c30240..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 @@ -54,8 +54,6 @@ interface SettingsPanelProps { description?: string /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */ docsLink?: string - /** Escape hatch for a right-aligned widget that genuinely cannot be a chip. Rare. */ - aside?: ReactNode /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */ scrollContainerRef?: Ref } @@ -76,7 +74,6 @@ export function SettingsPanel({ title, description, docsLink, - aside, scrollContainerRef, }: SettingsPanelProps) { const section = useSettingsSection() @@ -89,7 +86,6 @@ export function SettingsPanel({ back, actions, search, - aside, scrollContainerRef, }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index dfdd71bb9d3..60776f5da79 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -320,6 +320,7 @@ export function TeamManagement() { variant: 'primary', onSelect: () => setInviteModalOpen(true), disabled: isInvitationsDisabled, + tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined, }, ]} > diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index 31ed6774ffa..5ecf4da0c52 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Chip, ChipDropdown, Plus, toast } from '@sim/emcn' +import { ChipDropdown, Plus, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' @@ -170,18 +170,16 @@ export function Teammates() { onChange: setSearchTerm, placeholder: 'Search teammates...', }} - aside={ - - Invite - - } + actions={[ + { + text: 'Invite', + icon: Plus, + variant: 'primary', + onSelect: handleInvite, + tooltip: inviteDisabledReason ?? undefined, + onPrefetch: isInvitationsDisabled ? prefetchUpgrade : undefined, + }, + ]} > - setConfigTab(value as ConfigTab)} - /> +
+ setConfigTab(value as ConfigTab)} + /> +
{configTab === 'general' && ( <> From f174569d988cccc8f9aef522d3574659680f9831 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 21:04:53 -0700 Subject: [PATCH 6/6] =?UTF-8?q?refactor(settings):=20final=20audit=20pass?= =?UTF-8?q?=20=E2=80=94=20parity,=20sentence-case,=20import=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a line-by-line audit (+ React-docs validation of the click-time ref deref and isomorphic layout-effect patterns): - workflow-mcp detail: restore origin action order (Edit server, then the primary Add workflows) so the primary CTA stays rightmost. - Sentence-case the detail back-chip labels to match nav ('MCP tools', 'MCP servers'); fix the workflow-mcp list empty-state copy ('Add server'). - data-retention: import ArrowLeft from '@sim/emcn/icons' (canonical back-chip icon source, matching the other detail views); group-detail: import SettingsPanel from the package index, not the deep file path. - Document that scrollContainerRef is intentionally excluded from the header signature (refs are identity-stable). --- .../[workspaceId]/settings/components/mcp/mcp.tsx | 2 +- .../components/settings-header/settings-header.tsx | 4 +++- .../workflow-mcp-servers/workflow-mcp-servers.tsx | 10 +++++----- apps/sim/ee/access-control/components/group-detail.tsx | 2 +- .../components/data-retention-settings.tsx | 3 ++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 626eb0900d5..b0a185065b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -385,7 +385,7 @@ export function MCP() { return ( (null) /** * Serializes only the visible/structural fields — callbacks stay in the ref and * the shell dereferences them at call time, so registering never loops and never - * serves a stale handler. Any new VISIBLE field must be added here too. + * serves a stale handler. `scrollContainerRef` is intentionally excluded (refs are + * identity-stable and ride along with the first content-bearing register). Any new + * VISIBLE field must be added here too. */ function computeSignature(c: SettingsHeaderConfig): string { return JSON.stringify({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 8b789de1b1b..60deec155c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -353,12 +353,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro ) if (isLoading) { - return + return } if (error || !data) { return ( - +

Failed to load server details @@ -373,9 +373,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return ( <>

@@ -949,7 +949,7 @@ export function WorkflowMcpServers() {
) : isLoading ? null : !hasServers ? ( - Click "Add Server" above to get started + Click "Add server" above to get started ) : (
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index b538d512b46..dc1513346b0 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -36,7 +36,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { getAllBlocks } from '@/blocks' diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index c716319c60c..b9b08e656d9 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -12,11 +12,12 @@ import { Search, toast, } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' +import { ArrowRight, Plus } from 'lucide-react' import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization' import type { RetentionOverride } from '@/lib/api/contracts/primitives' import { useSession } from '@/lib/auth/auth-client'