Skip to content

Commit feaddc4

Browse files
authored
improvement(headers): one action cluster and one ordering app-wide (#6210)
Extends the settings-header ordering past detail pages. Five headers hand-rolled their own action wrapper. `resource-header` used `flex shrink-0 items-center` — no height, no gap, so chips on tables, files, knowledge, logs and scheduled tasks sat flush against each other; the integrations tab strip and the integration block detail each used `ml-auto flex items-center`. Only the settings shell and credential detail wore the intended `flex h-[30px] items-center gap-1`. That string is now HEADER_ACTION_CLUSTER, next to PAGE_HEADER_BAR, and all five compose it. `Resource.Header` now ranks its actions through orderHeaderActions too, so the resource pages inherit the same order as settings rather than rendering their array verbatim. `ResourceAction` gains `id`, which was the only field keeping it from being a subset of `SettingsAction`. Delete is now ranked by its `id` rather than by where the caller put it. That matters for a header with no primary action: the file detail listed `Download → Share → Delete`, leaving a destructive chip in the slot a primary would occupy. Tagging it `id:'delete'` fixes that without inventing a primary.
1 parent b741176 commit feaddc4

9 files changed

Lines changed: 65 additions & 19 deletions

File tree

.claude/rules/sim-settings-pages.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,20 @@ Every detail header reads left→right:
256256
```
257257

258258
You do not have to get the array order right — `orderHeaderActions()` ranks them
259-
(secondary → `id:'discard'``variant:'primary'`), order-stable within each
260-
band, so spreading `saveDiscardActions()` first still renders Save last. Both
261-
action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`.
259+
(secondary → `id:'delete'``id:'discard'``variant:'primary'`), order-stable
260+
within each band, so spreading `saveDiscardActions()` first still renders Save
261+
last. Three stacks apply it: `SettingsHeaderShell`, `SettingsActionChips`, and
262+
`Resource.Header` — so tables, files, knowledge and logs get the same ordering
263+
as settings.
264+
265+
Delete is placed by its **`id`**, not by position, which is why `id:'delete'` is
266+
required rather than cosmetic: a page with no primary action still must not
267+
leave a destructive chip in the slot a primary would occupy.
268+
269+
The bar geometry and the action cluster are both single-sourced in
270+
`@/components/page-header-bar``PAGE_HEADER_BAR` (or `Resource.Header`'s
271+
bordered variant) and `HEADER_ACTION_CLUSTER`. Never re-derive `h-[30px]`,
272+
`gap-1`, or the lane padding per header.
262273
Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx`
263274
— the latter pins that a reordered chip still routes to its own handler.
264275

apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from 'react'
22
import { cn } from '@sim/emcn'
3-
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
3+
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
44

55
interface CredentialDetailLayoutProps {
66
/** Back link rendered at the start of the fixed action bar. */
@@ -21,7 +21,7 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
2121
<div className='flex h-full flex-col bg-[var(--bg)]'>
2222
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
2323
{back}
24-
{actions ? <div className='flex h-[30px] items-center gap-1'>{actions}</div> : null}
24+
{actions ? <div className={HEADER_ACTION_CLUSTER}>{actions}</div> : null}
2525
</div>
2626
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
2727
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
} from '@sim/emcn'
3232
import { ArrowUpLeft } from 'lucide-react'
3333
import { createPortal } from 'react-dom'
34-
import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
34+
import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
35+
import { orderHeaderActions } from '@/components/settings/settings-header'
3536
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
3637

3738
export interface DropdownOption {
@@ -77,6 +78,13 @@ export interface BreadcrumbItem {
7778
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
7879
*/
7980
export interface ResourceAction {
81+
/**
82+
* Stable render identity, and the action's slot in the row. `'delete'` and
83+
* `'discard'` are ordered by {@link orderHeaderActions} rather than by where the
84+
* caller listed them; any other id is just a key. Falls back to `text`, which
85+
* remounts the chip whenever the label flips (Delete → Deleting...).
86+
*/
87+
id?: string
8088
icon?: ComponentType<{ className?: string }>
8189
text: string
8290
variant?: 'primary' | 'destructive'
@@ -202,11 +210,11 @@ export const ResourceHeader = memo(function ResourceHeader({
202210
)}
203211
</div>
204212
{(aside || (actions && actions.length > 0)) && (
205-
<div className='flex shrink-0 items-center'>
213+
<div className={cn(HEADER_ACTION_CLUSTER, 'shrink-0')}>
206214
{aside}
207-
{actions?.map((action) => (
215+
{orderHeaderActions(actions).map(({ action }) => (
208216
<Chip
209-
key={action.text}
217+
key={action.id ?? action.text}
210218
variant={action.variant}
211219
active={action.active}
212220
leftIcon={action.icon}

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,7 @@ export function Files() {
16341634
onSelect: handleShareSelected,
16351635
},
16361636
{
1637+
id: 'delete',
16371638
text: 'Delete',
16381639
icon: Trash,
16391640
onSelect: handleDeleteSelected,

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
55
import { ArrowLeft, Plus } from 'lucide-react'
66
import { useRouter } from 'next/navigation'
77
import { useQueryState } from 'nuqs'
8-
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
8+
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
99
import { isChatEnabled } from '@/lib/core/config/env-flags'
1010
import {
1111
blockTypeToIconMap,
@@ -146,7 +146,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
146146
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
147147
Integrations
148148
</ChipLink>
149-
<div className='ml-auto flex items-center'>
149+
<div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>
150150
{oauthService ? (
151151
hasServiceAccount ? (
152152
<ChipDropdown

apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from 'react'
2-
import { ChipLink } from '@sim/emcn'
3-
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
2+
import { ChipLink, cn } from '@sim/emcn'
3+
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
44

55
interface IntegrationTabsHeaderProps {
66
active: 'integrations' | 'skills'
@@ -26,7 +26,7 @@ export function IntegrationTabsHeader({
2626
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
2727
Skills
2828
</ChipLink>
29-
{rightSlot && <div className='ml-auto flex items-center'>{rightSlot}</div>}
29+
{rightSlot && <div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>{rightSlot}</div>}
3030
</div>
3131
)
3232
}

apps/sim/components/page-header-bar.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,12 @@ export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-b
2323
* Single source of truth for this geometry — never re-derive it per page.
2424
*/
2525
export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]`
26+
27+
/**
28+
* The right-hand action cluster inside a top bar. Every header — settings,
29+
* credential detail, `Resource` pages, the integrations tab strip — wears this,
30+
* so a chip row is the same height and rhythm wherever it appears.
31+
*
32+
* Single source of truth: never re-derive `h-[30px]`/`gap-1` per header.
33+
*/
34+
export const HEADER_ACTION_CLUSTER = 'flex h-[30px] items-center gap-1'

apps/sim/components/settings/settings-header-order.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ describe('orderHeaderActions', () => {
5656
expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows'])
5757
})
5858

59+
it('places Delete by its id, not by where the caller listed it', () => {
60+
// A page with no primary action still must not leave Delete in the slot a
61+
// primary would occupy — files detail is exactly this shape.
62+
const actions: SettingsAction[] = [
63+
{ id: 'delete', text: 'Delete', onSelect: noop },
64+
{ text: 'Download', onSelect: noop },
65+
{ text: 'Share', onSelect: noop },
66+
]
67+
68+
expect(rendered(actions)).toEqual(['Download', 'Share', 'Delete'])
69+
})
70+
5971
it('preserves caller order within a band', () => {
6072
const actions: SettingsAction[] = [
6173
{ text: 'Refresh', onSelect: noop },

apps/sim/components/settings/settings-header.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
useState,
1515
} from 'react'
1616
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
17-
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
17+
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
1818

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

@@ -191,9 +191,13 @@ export function SettingsActionChips({ actions }: { actions: SettingsAction[] })
191191
}
192192

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

0 commit comments

Comments
 (0)