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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,32 @@ 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(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
- **`saveDiscardActions(config)`** (`@/components/settings/save-discard-actions`)
— returns the canonical **Discard + Save** `SettingsAction[]`. **Save is always
rendered** (primary), disabled until there is something to save, so 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; **Discard appears only
when 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?`.
`saving`, `onSave`, `onDiscard`, `saveDisabled?`, `saveTooltip?`, `creating?`,
`saveLabel?`, `savingLabel?`. Create flows pass `creating` — the
Create / Creating... labels come as a pair and can never drift apart.
`saveLabel`/`savingLabel` are only for genuinely bespoke wording (SSO's
`Update`); never hand-roll the pair to get a create label.
- **`<SaveDiscardChips {...config} />`** (same module) — the identical rule
rendered as chips, for surfaces whose header takes a `ReactNode` instead of
action data (`CredentialDetailLayout`: skills, secrets, connected credentials).
Both stacks derive from the one function; never hand-roll a Save chip.

`CredentialDetailLayout` stays slot-driven for exactly two reasons: its back
control is a real `<ChipLink href>` (deep-linkable / middle-clickable, which
`SettingsBackAction`'s `onSelect` cannot express), and actions like
`SkillImportButton` own a hidden file input and their own pending state.
**Everything else in one of those headers should be `SettingsAction` data**
rendered through `<SettingsActionChips actions={…} />` from
`@/components/settings/settings-header` — that is the shared chip path, and it
is what keeps tone/icon/variant/tooltip handling from drifting between the two
shells. Reach for it before hand-rolling a `Chip`.
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
Expand Down Expand Up @@ -30,11 +30,21 @@ export function useCredentialDetailForm({

const [displayNameDraft, setDisplayNameDraft] = useState('')
const [descriptionDraft, setDescriptionDraft] = useState('')
const [seededCredentialId, setSeededCredentialId] = useState<string | null>(null)

useEffect(() => {
setDisplayNameDraft(credential?.displayName ?? '')
setDescriptionDraft(credential?.description ?? '')
}, [credential?.id, credential?.displayName, credential?.description])
// Seed drafts when the credential first resolves (or the route id changes); a
// background refetch of the same credential must not clobber an in-progress
// edit — Discard is the one way to reset.
/** Applies a credential to both drafts — the one definition of "reset to server state". */
const seedDrafts = useCallback((source: WorkspaceCredential) => {
setDisplayNameDraft(source.displayName)
setDescriptionDraft(source.description ?? '')
}, [])

if (credential && credential.id !== seededCredentialId) {
setSeededCredentialId(credential.id)
seedDrafts(credential)
}

const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
const isDescriptionDirty = credential
Expand Down Expand Up @@ -68,16 +78,22 @@ export function useCredentialDetailForm({
isDescriptionDirty,
displayNameDraft,
descriptionDraft,
updateCredential,
updateCredential.mutateAsync,
updateCredential.isPending,
])

const discard = useCallback(() => {
if (credential) seedDrafts(credential)
}, [credential, seedDrafts])

return {
displayNameDraft,
setDisplayNameDraft,
descriptionDraft,
setDescriptionDraft,
isDirty,
save,
discard,
isSaving: updateCredential.isPending,
handleBackClick: guard.handleBackClick,
showUnsavedAlert: guard.showUnsavedAlert,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ const MEMBERSHIP_OPTIONS = [

type Membership = (typeof MEMBERSHIP_OPTIONS)[number]['value']

const MEMBERSHIP_HINTS: Record<Membership, string> = {
member: 'Joins your organization. Adds a seat.',
const MEMBERSHIP_HINTS: Partial<Record<Membership, string>> = {
admin: 'Joins your organization and can manage it. Adds a seat.',
external:
'Access to the selected workspaces only — no seat. Only available for people already on a paid Sim plan.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { resolveCredentialDisplay } from '@/lib/integrations'
import {
Expand Down Expand Up @@ -204,9 +205,12 @@ export function ConnectedCredentialDetail({
>
Disconnect
</Chip>
<Chip onClick={form.save} disabled={!form.isDirty || form.isSaving}>
{form.isSaving ? 'Saving...' : 'Save'}
</Chip>
<SaveDiscardChips
dirty={form.isDirty}
saving={form.isSaving}
onSave={form.save}
onDiscard={form.discard}
/>
</>
) : null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChipConfirmModal, toast } from '@sim/emcn'
import { ArrowLeft, Wrench } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
import {
CredentialDetailHeading,
Expand All @@ -23,8 +24,6 @@ import {
useSchemaGeneration,
validateCustomToolSchema,
} from '@/app/workspace/[workspaceId]/components/custom-tool-editor'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
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'
Expand All @@ -50,9 +49,9 @@ interface CustomToolDetailProps {

/**
* Full-page custom tool editor rendered as a settings detail sub-view: a back
* chip, dirty-gated Discard/Save, Delete, and the Schema and Code editors
* stacked (no tabs — the page has room for both). Uses the same fields as the
* canvas modal so the two surfaces never drift.
* chip, Save/Discard, Delete, and the Schema and Code editors stacked (no tabs —
* the page has room for both). Uses the same fields as the canvas modal so the
* two surfaces never drift.
*/
export function CustomToolDetail({
workspaceId,
Expand Down Expand Up @@ -200,22 +199,6 @@ export function CustomToolDetail({
}
}

/**
* On create, the primary action is always visible so the page announces what
* it is for — disabled until the schema is a valid function definition.
* (`saveDiscardActions` is dirty-gated and would render nothing on an empty
* draft.) Discard still only appears once there is something to discard.
*/
const createToolActions: SettingsAction[] = [
...(dirty ? [{ text: 'Discard', onSelect: handleDiscard, disabled: saving }] : []),
{
text: saving ? 'Creating...' : 'Create',
variant: 'primary' as const,
onSelect: handleSave,
disabled: saving || streaming || !isSchemaValid,
},
]

return (
<>
<SettingsPanel
Expand All @@ -224,15 +207,14 @@ export function CustomToolDetail({
actions={[
...(readOnly
? []
: isEditing
? saveDiscardActions({
dirty,
saving,
onSave: handleSave,
onDiscard: handleDiscard,
saveDisabled: !isSchemaValid || streaming,
})
: createToolActions),
: saveDiscardActions({
dirty,
saving,
onSave: handleSave,
onDiscard: handleDiscard,
saveDisabled: !isSchemaValid || streaming,
creating: !isEditing,
})),
...(tool && !readOnly
? [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { CodeIcon } from '@/components/icons'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
Expand All @@ -29,7 +30,6 @@ import {
type SandboxDraft,
toSubmittedLines,
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
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 { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
Expand Down Expand Up @@ -202,6 +202,7 @@ export function Sandboxes() {
setIssues([])
},
saveDisabled: !canAdmin || current.name.trim().length === 0,
creating: isCreating,
}),
...(selected && canAdmin
? [
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import {
clearPendingCredentialCreateRequest,
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
Expand All @@ -17,7 +18,6 @@ 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 { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { isValidEnvVarName } from '@/executor/constants'
Expand Down Expand Up @@ -981,27 +981,18 @@ export function SecretsManager() {
onChange: setSearchTerm,
placeholder: 'Search secrets...',
}}
actions={[
...(hasChanges
? [
{
text: 'Discard',
onSelect: handleCancel,
disabled: 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,
},
]}
actions={saveDiscardActions({
dirty: hasChanges,
saving: isListSaving,
onSave: handleSave,
onDiscard: handleCancel,
saveDisabled: hasConflicts || hasInvalidKeys || isLoading,
saveTooltip: hasConflicts
? 'Resolve all conflicts before saving'
: hasInvalidKeys
? 'Fix invalid variable names before saving'
: undefined,
})}
>
{!isLoading && (
<div className='flex flex-col gap-7'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,9 +52,12 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
</Chip>
)}
{canEditValue && (
<Chip onClick={valueField.save} disabled={!valueField.isDirty || valueField.isSaving}>
{valueField.isSaving ? 'Saving...' : 'Save'}
</Chip>
<SaveDiscardChips
dirty={valueField.isDirty}
saving={valueField.isSaving}
onSave={valueField.save}
onDiscard={valueField.discard}
/>
)}
</>
) : null
Expand Down
Loading
Loading