diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 7438f5aaf5e..2cff0545957 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -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. diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index b6edcb5801d..8efb140820d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -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. */ @@ -21,7 +21,7 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
{back} - {actions ?
{actions}
: null} + {actions ?
{actions}
: null}
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index df0960385c5..9f295fe229a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -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 { @@ -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' @@ -202,11 +210,11 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
{(aside || (actions && actions.length > 0)) && ( -
+
{aside} - {actions?.map((action) => ( + {orderHeaderActions(actions).map(({ action }) => ( Integrations -
+
{oauthService ? ( hasServiceAccount ? ( Skills - {rightSlot &&
{rightSlot}
} + {rightSlot &&
{rightSlot}
}
) } diff --git a/apps/sim/components/page-header-bar.ts b/apps/sim/components/page-header-bar.ts index 1c1fb8e5ce9..23436cad2e2 100644 --- a/apps/sim/components/page-header-bar.ts +++ b/apps/sim/components/page-header-bar.ts @@ -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' diff --git a/apps/sim/components/settings/settings-header-order.test.ts b/apps/sim/components/settings/settings-header-order.test.ts index a648c32d42a..d2700d7b146 100644 --- a/apps/sim/components/settings/settings-header-order.test.ts +++ b/apps/sim/components/settings/settings-header-order.test.ts @@ -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 }, diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index c194d435b23..91747443095 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -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 @@ -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 @@ -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 ?? []) @@ -233,7 +238,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { ) : (
)} -
+
{docsLink && ( Docs