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
76 changes: 76 additions & 0 deletions .claude/rules/sim-list-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
paths:
- "apps/sim/app/**/*.tsx"
- "apps/sim/ee/**/*.tsx"
- "apps/sim/components/**/*.tsx"
---

# List & Menu Ordering

**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.

This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.

## The rule

Before writing a list of items, find where the user sees those same items *first*. That surface owns the order; your list mirrors it.

| The list | Mirrors |
| --- | --- |
| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
| A "New …" menu | the order those things appear once created |

Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last.

Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.

## Encode the order once

An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu.

```ts
/** Top-down order for every menu listing resource families, mirroring the sidebar. */
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
'integration', 'task', 'table', 'file', 'filefolder',
'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
]

export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(a: T, b: T) {
return RESOURCE_MENU_ORDER.indexOf(a.type) - RESOURCE_MENU_ORDER.indexOf(b.type)
}
```

Canonical instance: `app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx`, consumed by `useAvailableResources` and `ResourceMenuSections`.

## Render kinds in one pass, not one phase per kind

The most common way a canonical order gets silently defeated: emitting all items of one *kind* and then all of another. Every submenu-backed family lands above every flat family regardless of what the order constant says.

```tsx
// ✗ Bad — two phases; the trees always pin to the top
<ResourceTreeSections sections={treeSections} />
{groups.filter((g) => !FOLDERED.has(g.type)).map(renderFlat)}

// ✓ Good — one ordered pass; each entry picks its own rendering
{entries.sort(byResourceMenuOrder).map((entry) =>
sectionByType.has(entry.type) ? renderTree(entry) : renderFlat(entry)
)}
```

The same trap appears as "render the pinned ones, then the rest", "render enabled, then disabled", and "render the groups, then the loose items".

## When order may diverge

Only for reasons the user can perceive:

- **Search/filter results** rank by match quality — the whole point is that ranking beats position.
- **User-controlled ordering** (drag-to-reorder, manual `sortOrder`) wins over any canonical order.
- **Recency lists** ("Recent chats") order by time, which *is* the order the user reads them elsewhere.

"Grouped by which hook provides it", "alphabetical because it was easy", and "that's the order the array was built in" are not reasons.

## Reviewing

When a diff adds or edits a list of items, ask: where does the user see this set already, and does this match? If the answer is a different file with a different order, the diff needs a shared constant, not a second literal.
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ Shareable *client* view-state (active tab/panel, filters, search query, paginati

Co-locate a `search-params.ts` per feature exporting the parser map (single source of truth, shared by client `useQueryStates`/`useQueryState` and server `createSearchParamsCache`). Never `import { z }` in client code for params — use nuqs parsers. Full decision framework, conventions, the debounced-input pattern, and the workflow-editor carve-out are in `.claude/rules/sim-url-state.md`.

## List & Menu Ordering

A list orders itself the way the user already reads the same things somewhere else. Resource menus (`+` attach, `@` mention, resource-tab `+`) mirror the **sidebar** top-down; a row or root **context menu** mirrors that surface's **toolbar**, left-to-right becoming top-to-bottom; tab strips mirror their nav. Platform-only entries (desktop Browser, Terminal) trail the shared set.

Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (`RESOURCE_MENU_ORDER` / `byResourceMenuOrder` in `home/components/mothership-view/components/resource-registry`). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency. Full rule in `.claude/rules/sim-list-ordering.md`.

## Styling

Use Tailwind only, no inline styles. Use `cn()` from `@sim/emcn` for conditional classes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onCreateFile && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateFile}>
<Plus />
New file
{/* Upload, New folder, New file — the order the page header presents
them once `orderHeaderActions` has pinned the primary action last. */}
{onUploadFile && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
<Upload />
Upload file
</DropdownMenuItem>
)}
{onCreateFolder && (
Expand All @@ -55,10 +57,10 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
New folder
</DropdownMenuItem>
)}
{onUploadFile && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
<Upload />
Upload file
{onCreateFile && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateFile}>
<Plus />
New file
</DropdownMenuItem>
)}
</DropdownMenuContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
buildResourceFolderTree,
type ResourceTreeNode,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
byResourceMenuOrder,
getResourceConfig,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
Expand Down Expand Up @@ -297,7 +300,7 @@ export function useAvailableResources(
],
})
}
return groups.filter((g) => !excluded.has(g.type))
return groups.filter((g) => !excluded.has(g.type)).sort(byResourceMenuOrder)
}, [
enabled,
workflows,
Expand Down Expand Up @@ -414,12 +417,17 @@ interface FolderedSectionSpec {
orderBySortOrder?: boolean
}

/** Single source of truth for the foldered submenus, in display order. */
/**
* Single source of truth for the foldered submenus. Declared in
* {@link RESOURCE_MENU_ORDER}; the merge in {@link ResourceMenuSections} is what
* actually positions them among the flat families, so this order only has to agree
* with the canonical one rather than carry it.
*/
const FOLDERED_SECTION_SPECS: readonly FolderedSectionSpec[] = [
{ type: 'workflow', folders: { kind: 'group', type: 'folder' }, orderBySortOrder: true },
{ type: 'file', folders: { kind: 'group', type: 'filefolder' }, folderType: 'filefolder' },
{ type: 'table', folders: { kind: 'structure', key: 'table' } },
{ type: 'file', folders: { kind: 'group', type: 'filefolder' }, folderType: 'filefolder' },
{ type: 'knowledgebase', folders: { kind: 'structure', key: 'knowledgebase' } },
{ type: 'workflow', folders: { kind: 'group', type: 'folder' }, orderBySortOrder: true },
]

/**
Expand Down Expand Up @@ -464,8 +472,11 @@ export function useResourceTreeSections({
}, [groups, structureFolders])
}

interface ResourceTreeSectionsProps {
interface ResourceMenuSectionsProps {
/** Foldered families, from {@link useResourceTreeSections}. */
sections: ResourceTreeSection[]
/** Every available family. Foldered ones are taken from `sections` instead. */
groups: AvailableItemsByType[]
onSelect: (resource: MothershipResource) => void
/**
* Width override for the submenu panels. The chat menu widens them past the
Expand All @@ -475,30 +486,72 @@ interface ResourceTreeSectionsProps {
subContentClassName?: string
}

/** Renders {@link useResourceTreeSections} output as one submenu per family. */
export function ResourceTreeSections({
/**
* Renders every resource family as one submenu, foldered and flat interleaved in
* {@link RESOURCE_MENU_ORDER}. Rendering the two kinds in one pass is what lets a
* foldered family (Tables) sit above a flat one (Logs) — emitting all the trees
* and then all the flat families would pin every tree to the top regardless of the
* canonical order.
*/
export function ResourceMenuSections({
sections,
groups,
onSelect,
subContentClassName,
}: ResourceTreeSectionsProps) {
}: ResourceMenuSectionsProps) {
const sectionByType = new Map(sections.map((section) => [section.type, section]))
const entries = groups
.filter(({ type, items }) =>
FOLDERED_RESOURCE_TYPES.has(type) ? sectionByType.has(type) : items.length > 0
)
.sort(byResourceMenuOrder)

return (
<>
{sections.map((section) => {
const config = getResourceConfig(section.type)
const SectionIcon = config.icon
{entries.map(({ type, items }) => {
const config = getResourceConfig(type)
const Icon = config.icon
const section = sectionByType.get(type)

// Browser and terminal each have one top-level panel — a flat launcher
// here creates inner tabs when that panel already exists.
if (!section && (type === 'browser' || type === 'terminal')) {
const item = items[0]
return (
<DropdownMenuItem
key={type}
onClick={() => onSelect({ type, id: item.id, title: item.name })}
>
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuItem>
)
}

return (
<DropdownMenuSub key={section.type}>
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<SectionIcon className='size-[14px]' />
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={subContentClassName}>
<ResourceFolderTreeItems
nodes={section.nodes}
type={section.type}
folderType={section.folderType}
onSelect={onSelect}
/>
{section ? (
<ResourceFolderTreeItems
nodes={section.nodes}
type={section.type}
folderType={section.folderType}
onSelect={onSelect}
/>
) : (
items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => onSelect({ type, id: item.id, title: item.name })}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
Expand Down Expand Up @@ -650,47 +703,7 @@ export function AddResourceDropdown({
</div>
)
) : (
<>
<ResourceTreeSections sections={treeSections} onSelect={select} />
{available.map(({ type, items }) => {
if (FOLDERED_RESOURCE_TYPES.has(type)) return null
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
// Browser and terminal each have one top-level panel — flat
// launchers here create inner tabs when that panel exists.
if (type === 'browser' || type === 'terminal') {
const item = items[0]
return (
<DropdownMenuItem
key={type}
onClick={() => select({ type, id: item.id, title: item.name })}
>
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='size-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => select({ type, id: item.id, title: item.name })}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
<ResourceMenuSections sections={treeSections} groups={available} onSelect={select} />
)}
</div>
</DropdownMenuContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export {
AddResourceDropdown,
FOLDERED_RESOURCE_TYPES,
ResourceFolderTreeItems,
ResourceTreeSections,
ResourceMenuSections,
useAvailableResources,
useResourceTreeSections,
} from './add-resource-dropdown'
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@ export {
getResourceConfig,
invalidateResourceQueries,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'
export { ResourceTabs } from './resource-tabs'
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export type { ResourceTypeConfig } from './resource-registry'
export {
byResourceMenuOrder,
getResourceConfig,
invalidateResourceQueries,
RESOURCE_MENU_ORDER,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,36 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
},
} as const

export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
/**
* Top-down order for every menu that lists resource families, mirroring the
* workspace sidebar so a user reads the same sequence in both places. The two
* desktop-only panels trail the workspace resources, matching where they surface
* in the app. `folder`/`filefolder` never render as their own entry — they feed
* their family's folder tree — but are ordered beside it so a menu that ever does
* surface them lands in the right place.
*/
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
'integration',
'task',
'table',
'file',
'filefolder',
'knowledgebase',
'log',
'workflow',
'folder',
'browser',
'terminal',
'generic',
]

/** Sorts anything keyed by resource type into {@link RESOURCE_MENU_ORDER}. */
export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(
a: T,
b: T
): number {
return RESOURCE_MENU_ORDER.indexOf(a.type) - RESOURCE_MENU_ORDER.indexOf(b.type)
}

export function getResourceConfig(type: MothershipResourceType): ResourceTypeConfig {
return RESOURCE_REGISTRY[type]
Expand Down
Loading
Loading