diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
index 394aa641cab..8e246fb6de4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
@@ -97,5 +97,16 @@ export function useSecretValue({ workspaceId, credential }: UseSecretValueParams
}
}
- return { value: draft, setValue: setDraft, canEdit, isConflicted, isDirty, save, isSaving }
+ const discard = () => setDraft(currentValue)
+
+ return {
+ value: draft,
+ setValue: setDraft,
+ canEdit,
+ isConflicted,
+ isDirty,
+ save,
+ discard,
+ isSaving,
+ }
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index 7aca00a8c7f..b1412d0d7f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn'
import { ArrowLeft, Key } from '@sim/emcn/icons'
+import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
import {
AddPeopleModal,
CredentialDetailHeading,
@@ -51,9 +52,12 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
)}
{canEditValue && (
-
- {valueField.isSaving ? 'Saving...' : 'Save'}
-
+
)}
>
) : null
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
index 68597185bd4..9c37ba94fdb 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
@@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { AddPeopleModal } from '@/components/permissions'
+import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import {
CredentialDetailHeading,
@@ -36,7 +37,7 @@ interface SkillDetailProps {
/**
* Full-page skill detail, mirroring the integration credential detail surface:
- * a fixed action bar (Share / Delete / Save), a heading, editable Name /
+ * a fixed action bar (Share / Delete / Discard / Save), a heading, editable Name /
* Description / Content sections, and the Skill Editors roster. Non-editors
* and built-in template skills render read-only.
*/
@@ -71,15 +72,20 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [prevSkillId, setPrevSkillId] = useState
(null)
+ /** Applies a full skill shape to all three drafts and remounts the Content editor. */
+ const seedDrafts = (source: { name: string; description: string; content: string }) => {
+ setNameDraft(source.name)
+ setDescriptionDraft(source.description)
+ setContentDraft(source.content)
+ setErrors({})
+ setContentSeed((seed) => seed + 1)
+ }
+
// Seed drafts when the skill first resolves (or the route id changes); a
// background refetch of the same skill must not clobber an in-progress edit.
if (skill && skill.id !== prevSkillId) {
setPrevSkillId(skill.id)
- setNameDraft(skill.name)
- setDescriptionDraft(skill.description)
- setContentDraft(skill.content)
- setErrors({})
- setContentSeed((seed) => seed + 1)
+ seedDrafts(skill)
}
const isDirty =
@@ -155,11 +161,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
const handleContentPaste = (text: string): boolean => {
const parsed = parseSkillMarkdown(text)
if (!parsed.nameFromFrontmatter) return false
- setNameDraft(parsed.name)
- setDescriptionDraft(parsed.description)
- setContentDraft(parsed.content)
- setErrors({})
- setContentSeed((seed) => seed + 1)
+ seedDrafts(parsed)
return true
}
@@ -169,6 +171,10 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
)
+ const handleDiscard = () => {
+ if (skill) seedDrafts(skill)
+ }
+
const actions =
skill && canEdit ? (
<>
@@ -178,9 +184,12 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
setShowDeleteConfirm(true)} disabled={deleteSkill.isPending}>
Delete
-
- {updateSkill.isPending ? 'Saving...' : 'Save'}
-
+
>
) : null
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
index 5eaddfeee45..873e7aa9df9 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
@@ -1,11 +1,12 @@
'use client'
import { useState } from 'react'
-import { Chip, ChipLink, toast } from '@sim/emcn'
+import { ChipLink, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
+import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import {
CredentialDetailHeading,
@@ -34,7 +35,7 @@ interface SkillCreateProps {
/**
* Full-page skill creation, mirroring the skill detail surface: a fixed action
- * bar (Import / Create skill), a heading, and the editable Name / Description /
+ * bar (Import / Discard / Create), a heading, and the editable Name / Description /
* Content sections. Importing a SKILL.md prefills all three fields in place.
*/
export function SkillCreate({ workspaceId }: SkillCreateProps) {
@@ -90,10 +91,11 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
}
}
- const applyImportedSkill = (data: ParsedSkill) => {
- setNameDraft(data.name)
- setDescriptionDraft(data.description)
- setContentDraft(data.content)
+ /** Applies a full skill shape to all three drafts and remounts the Content editor. */
+ const seedDrafts = (source: Pick) => {
+ setNameDraft(source.name)
+ setDescriptionDraft(source.description)
+ setContentDraft(source.content)
setErrors({})
setContentSeed((seed) => seed + 1)
}
@@ -106,7 +108,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
const handleContentPaste = (text: string): boolean => {
const parsed = parseSkillMarkdown(text)
if (!parsed.nameFromFrontmatter) return false
- applyImportedSkill(parsed)
+ seedDrafts(parsed)
return true
}
@@ -116,12 +118,18 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
)
+ const handleDiscard = () => seedDrafts({ name: '', description: '', content: '' })
+
const actions = (
<>
-
-
- {createSkill.isPending ? 'Creating...' : 'Create'}
-
+
+
>
)
diff --git a/apps/sim/components/settings/save-discard-actions.tsx b/apps/sim/components/settings/save-discard-actions.tsx
new file mode 100644
index 00000000000..339f1ede0ab
--- /dev/null
+++ b/apps/sim/components/settings/save-discard-actions.tsx
@@ -0,0 +1,58 @@
+import { type SettingsAction, SettingsActionChips } from '@/components/settings/settings-header'
+
+export interface SaveDiscardActionsConfig {
+ dirty: boolean
+ saving: boolean
+ onSave: () => void
+ onDiscard: () => void
+ saveDisabled?: boolean
+ /** Labels the primary action `Create` / `Creating...` instead of `Save` / `Saving...`. */
+ creating?: boolean
+ /** Bespoke wording (e.g. `Update`). Prefer `creating` for the standard create flow. */
+ saveLabel?: string
+ /** Bespoke in-flight wording. Pair it with `saveLabel` so the two can't drift. */
+ savingLabel?: string
+ /** Explains a `saveDisabled` Save on hover — Save is always visible, so a blocked one should say why. */
+ saveTooltip?: string
+}
+
+/**
+ * The canonical Save/Discard header actions for any editable surface.
+ *
+ * Save is always rendered — every editable surface announces its primary action
+ * in the same place, and a create form is never a page with no visible way to
+ * commit it — and is disabled until there is something to save. Discard only
+ * appears once there is something to discard.
+ */
+export function saveDiscardActions({
+ dirty,
+ saving,
+ onSave,
+ onDiscard,
+ saveDisabled = false,
+ creating = false,
+ saveLabel = creating ? 'Create' : 'Save',
+ savingLabel = creating ? 'Creating...' : 'Saving...',
+ saveTooltip,
+}: SaveDiscardActionsConfig): SettingsAction[] {
+ return [
+ ...(dirty ? [{ id: 'discard', text: 'Discard', onSelect: onDiscard, disabled: saving }] : []),
+ {
+ id: 'save',
+ text: saving ? savingLabel : saveLabel,
+ variant: 'primary',
+ onSelect: onSave,
+ disabled: saving || saveDisabled || !dirty,
+ tooltip: saveTooltip,
+ },
+ ]
+}
+
+/**
+ * {@link saveDiscardActions} rendered as chips, for surfaces whose header takes
+ * a `ReactNode` (`CredentialDetailLayout`) instead of the settings shell's
+ * action data. Both stacks derive their chips from the one rule above.
+ */
+export function SaveDiscardChips(config: SaveDiscardActionsConfig) {
+ return
+}
diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx
index 98d507b3d01..9093b4d70b4 100644
--- a/apps/sim/components/settings/settings-header.tsx
+++ b/apps/sim/components/settings/settings-header.tsx
@@ -19,6 +19,8 @@ import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
export interface SettingsAction {
+ /** Stable render identity. Falls back to `text`, which remounts the chip whenever the label flips (Save → Saving...). */
+ id?: string
text: string
textTone?: 'error'
icon?: ComponentType<{ className?: string }>
@@ -116,6 +118,75 @@ export function useSettingsHeader(config: SettingsHeaderConfig) {
}, [register])
}
+interface SettingsActionChipProps {
+ /** Presentation fields plus the default `onSelect` / `onPrefetch` handlers. */
+ action: SettingsAction
+ /** Overrides `action.onSelect` — the header shell passes a ref-reading indirection to avoid stale closures. */
+ onSelect?: () => void
+ /** Overrides `action.onPrefetch`, same reason. */
+ onPrefetch?: () => void
+}
+
+/**
+ * The one chip rendering of a {@link SettingsAction}. Both header stacks render
+ * through this, so an action's chrome — tone, icon, variant, disabled, tooltip —
+ * is identical wherever it is mounted. Container spacing still belongs to the
+ * enclosing shell.
+ */
+export function SettingsActionChip({
+ action,
+ onSelect = action.onSelect,
+ onPrefetch = action.onPrefetch,
+}: SettingsActionChipProps) {
+ const chip = (
+ is not a hit-test target, so the tooltip's wrapping
+ // span would never see pointerenter and the explanation would never show.
+ className={cn(action.tooltip && action.disabled && 'pointer-events-none')}
+ >
+ {action.textTone === 'error' ? (
+ {action.text}
+ ) : (
+ action.text
+ )}
+
+ )
+ if (!action.tooltip) return chip
+ return (
+
+
+ {chip}
+
+ {action.tooltip}
+
+ )
+}
+
+/**
+ * Renders a {@link SettingsAction} list as chips using each action's own
+ * handlers. This is the data path for headers that take a `ReactNode`
+ * (`CredentialDetailLayout`) — reach for it instead of hand-rolling `Chip`s, so
+ * those surfaces keep the same chrome as the settings shell. The shell itself
+ * maps through {@link SettingsActionChip} directly, since it must route every
+ * handler through a ref to avoid stale closures.
+ */
+export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) {
+ return (
+ <>
+ {actions.map((action) => (
+
+ ))}
+ >
+ )
+}
+
export function SettingsHeaderShell({ children }: { children: ReactNode }) {
const read = useContext(ReadContext)
const configRef = read?.configRef
@@ -138,44 +209,18 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) {
Docs
)}
- {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.textTone === 'error' ? (
- {action.text}
- ) : (
- action.text
- )}
-
- )
- return action.tooltip ? (
-
-
- {chip}
-
- {action.tooltip}
-
- ) : (
- chip
- )
- })}
+ {actions?.map((action, index) => (
+ configRef?.current.actions?.[index]?.onSelect()}
+ onPrefetch={
+ action.onPrefetch
+ ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
+ : undefined
+ }
+ />
+ ))}
({
VerifiedDomainsSection: () =>
,
}))
-vi.mock(
- '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions',
- () => ({
- saveDiscardActions: () => [],
- })
-)
+vi.mock('@/components/settings/save-discard-actions', () => ({
+ saveDiscardActions: () => [],
+}))
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
,
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index 959581bd9c8..b7f3df18a85 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -17,14 +17,14 @@ import {
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Eye, EyeOff } from 'lucide-react'
+import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import type { SettingsAction } from '@/components/settings/settings-header'
import type { SsoRegistrationBody } from '@/lib/api/contracts/auth'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
-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'
diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
index deecd10d1ce..64dedd8a589 100644
--- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
+++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { Image as ImageIcon, X } from 'lucide-react'
import Image from 'next/image'
+import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
@@ -13,7 +14,6 @@ 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 { 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'
diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx
index 38df0a0cead..550be464c49 100644
--- a/apps/sim/ee/workspace-forking/components/forks.tsx
+++ b/apps/sim/ee/workspace-forking/components/forks.tsx
@@ -7,6 +7,8 @@ import { getErrorMessage } from '@sim/utils/errors'
import { AlertTriangle, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
+import { saveDiscardActions } from '@/components/settings/save-discard-actions'
+import type { SettingsAction } from '@/components/settings/settings-header'
import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
@@ -24,9 +26,7 @@ import {
type RowAction,
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 { 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 { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'