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.
This commit is contained in:
Waleed
2026-08-03 10:31:52 -07:00
committed by GitHub
parent b741176d4b
commit feaddc437a
9 changed files with 65 additions and 19 deletions
+14 -3
View File
@@ -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.
@@ -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
<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>
@@ -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({
)}
</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}
@@ -1634,6 +1634,7 @@ export function Files() {
onSelect: handleShareSelected,
},
{
id: 'delete',
text: 'Delete',
icon: Trash,
onSelect: handleDeleteSelected,
@@ -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,
@@ -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
@@ -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'
@@ -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
View File
@@ -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'
@@ -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 },
@@ -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 }) {
) : (
<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