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
17 changes: 14 additions & 3 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,20 @@ Every detail header reads left→right:
```

You do not have to get the array order right — `orderHeaderActions()` ranks them
(secondary → `id:'discard'` → `variant:'primary'`), order-stable within each
band, so spreading `saveDiscardActions()` first still renders Save last. Both
action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`.
(secondary → `id:'delete'` → `id:'discard'` → `variant:'primary'`), order-stable
within each band, so spreading `saveDiscardActions()` first still renders Save
last. Three stacks apply it: `SettingsHeaderShell`, `SettingsActionChips`, and
`Resource.Header` — so tables, files, knowledge and logs get the same ordering
as settings.

Delete is placed by its **`id`**, not by position, which is why `id:'delete'` is
required rather than cosmetic: a page with no primary action still must not
leave a destructive chip in the slot a primary would occupy.

The bar geometry and the action cluster are both single-sourced in
`@/components/page-header-bar` — `PAGE_HEADER_BAR` (or `Resource.Header`'s
bordered variant) and `HEADER_ACTION_CLUSTER`. Never re-derive `h-[30px]`,
`gap-1`, or the lane padding per header.
Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx`
— the latter pins that a reordered chip still routes to its own handler.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'

interface CredentialDetailLayoutProps {
/** Back link rendered at the start of the fixed action bar. */
Expand All @@ -21,7 +21,7 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
{back}
{actions ? <div className='flex h-[30px] items-center gap-1'>{actions}</div> : null}
{actions ? <div className={HEADER_ACTION_CLUSTER}>{actions}</div> : null}
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import {
} from '@sim/emcn'
import { ArrowUpLeft } from 'lucide-react'
import { createPortal } from 'react-dom'
import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
import { orderHeaderActions } from '@/components/settings/settings-header'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'

export interface DropdownOption {
Expand Down Expand Up @@ -77,6 +78,13 @@ export interface BreadcrumbItem {
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
*/
export interface ResourceAction {
/**
* Stable render identity, and the action's slot in the row. `'delete'` and
* `'discard'` are ordered by {@link orderHeaderActions} rather than by where the
* caller listed them; any other id is just a key. Falls back to `text`, which
* remounts the chip whenever the label flips (Delete → Deleting...).
*/
id?: string
icon?: ComponentType<{ className?: string }>
text: string
variant?: 'primary' | 'destructive'
Expand Down Expand Up @@ -202,11 +210,11 @@ export const ResourceHeader = memo(function ResourceHeader({
)}
</div>
{(aside || (actions && actions.length > 0)) && (
<div className='flex shrink-0 items-center'>
<div className={cn(HEADER_ACTION_CLUSTER, 'shrink-0')}>
{aside}
{actions?.map((action) => (
{orderHeaderActions(actions).map(({ action }) => (
<Chip
key={action.text}
key={action.id ?? action.text}
variant={action.variant}
active={action.active}
leftIcon={action.icon}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1634,6 +1634,7 @@ export function Files() {
onSelect: handleShareSelected,
},
{
id: 'delete',
text: 'Delete',
icon: Trash,
onSelect: handleDeleteSelected,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
import { ArrowLeft, Plus } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import {
blockTypeToIconMap,
Expand Down Expand Up @@ -146,7 +146,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
<div className='ml-auto flex items-center'>
<div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>
{oauthService ? (
hasServiceAccount ? (
<ChipDropdown
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { ChipLink } from '@sim/emcn'
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { ChipLink, cn } from '@sim/emcn'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'

interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
Expand All @@ -26,7 +26,7 @@ export function IntegrationTabsHeader({
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
Skills
</ChipLink>
{rightSlot && <div className='ml-auto flex items-center'>{rightSlot}</div>}
{rightSlot && <div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>{rightSlot}</div>}
</div>
)
}
9 changes: 9 additions & 0 deletions apps/sim/components/page-header-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-b
* Single source of truth for this geometry — never re-derive it per page.
*/
export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]`

/**
* The right-hand action cluster inside a top bar. Every header — settings,
* credential detail, `Resource` pages, the integrations tab strip — wears this,
* so a chip row is the same height and rhythm wherever it appears.
*
* Single source of truth: never re-derive `h-[30px]`/`gap-1` per header.
*/
export const HEADER_ACTION_CLUSTER = 'flex h-[30px] items-center gap-1'
12 changes: 12 additions & 0 deletions apps/sim/components/settings/settings-header-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ describe('orderHeaderActions', () => {
expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows'])
})

it('places Delete by its id, not by where the caller listed it', () => {
// A page with no primary action still must not leave Delete in the slot a
// primary would occupy — files detail is exactly this shape.
const actions: SettingsAction[] = [
{ id: 'delete', text: 'Delete', onSelect: noop },
{ text: 'Download', onSelect: noop },
{ text: 'Share', onSelect: noop },
]

expect(rendered(actions)).toEqual(['Download', 'Share', 'Delete'])
})

it('preserves caller order within a band', () => {
const actions: SettingsAction[] = [
{ text: 'Refresh', onSelect: noop },
Expand Down
15 changes: 10 additions & 5 deletions apps/sim/components/settings/settings-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useState,
} from 'react'
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'

const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect

Expand Down Expand Up @@ -191,9 +191,13 @@ export function SettingsActionChips({ actions }: { actions: SettingsAction[] })
}

/**
* Every detail header reads left→right as
* Every header reads left→right as
* `[secondary actions] → [Delete] → [Discard] → [Save]`.
*
* Delete is placed by its `id`, not by where the caller happened to put it, so a
* page with no primary action still can't leave a destructive chip in the slot a
* primary would occupy.
*
* The shell enforces it rather than trusting callsites, because the natural way
* to write the array — spreading {@link saveDiscardActions} first, then adding a
* Delete — produces the opposite order and puts a destructive chip to the right
Expand All @@ -208,8 +212,9 @@ export function orderHeaderActions(
actions: SettingsAction[] | undefined
): { action: SettingsAction; index: number }[] {
const rank = (action: SettingsAction) => {
if (action.variant === 'primary') return 2
if (action.id === 'discard') return 1
if (action.variant === 'primary') return 3
if (action.id === 'discard') return 2
if (action.id === 'delete') return 1
return 0
}
return (actions ?? [])
Expand All @@ -233,7 +238,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) {
) : (
<div />
)}
<div className='flex h-[30px] items-center gap-1'>
<div className={HEADER_ACTION_CLUSTER}>
{docsLink && (
<ChipLink href={docsLink} target='_blank' rel='noopener noreferrer'>
Docs
Expand Down
Loading