Skip to content

Commit 107748d

Browse files
authored
improvement(ui): consolidate the disclosure chevron and order resource menus like the sidebar (#6447)
* improvement(ui): consolidate the disclosure chevron and order resource menus like the sidebar #6400 aligned the workflow-tree folder chevron to the sidebar section header (16px/100ms -> 14px/150ms) but left the literal hand-copied in three files, so the next edit could drift them apart again. Extracts `disclosureChevronClass` into chip-chrome as the single source and points the section header and both sidebar trees at it. Adds `chipIconSlotClass` for the 16px slot that ChipChevronDown, ChipSelect, ChipDropdown and ChipDatePicker each re-derived. Both Back buttons rendered a ChevronDown rotated 90deg at 10px — wrong glyph, and 4px under every other chevron in the app; they are now ChevronLeft at 14px in that shared slot. The sidebar file tree drew hand-inlined SVGs at strokeWidth 2 next to the workflow tree's emcn icons at 1.55, so the two trees showed different folder glyphs; both now use the emcn set. Its file rows also indented by the chevron's width alone, landing their icon 4px left of a folder's and 2px smaller despite the comment claiming they lined up — they now clear the chevron plus the row gap at the same 16px size. Separately, the chat `+`/`@` resource menus rendered every foldered family as a submenu and only then the flat ones, which pinned Workflows to the top regardless of any ordering. Adds `RESOURCE_MENU_ORDER` mirroring the sidebar top-down and merges both kinds into one ordered pass, so the menus read Integrations, Chats, Tables, Files, Knowledge Bases, Logs, Workflows, then Browser and Terminal on desktop. Documents the general rule in .claude/rules/sim-list-ordering.md. * improvement(ui): order the remaining resource menus the way the user reads them An audit of every menu, tab strip, and context menu against the surface the user already reads those items on turned up six more divergences. The table row context menu put View execution before the Run/Re-run/Stop cluster while the action bar puts it after, so the same four actions read in two orders; the menu now follows the bar. The Files, Tables, and Knowledge empty-space create menus were each the exact reverse of their own page header, and each named the same action differently ("Create table" vs "New table", "Add knowledge base" vs "New base") — reversed to match, labels unified. Recently Deleted led with Workflows and buried Chats last, and put Knowledge Bases ahead of Files; it now follows the sidebar after the default All tab. The command palette hoisted Workflows from last to second for the same reason, and is now in sidebar order too. Chats stays first in both, so the palette's Enter default is unchanged. Drops the unused RESOURCE_TYPES export: nothing consumed it, and its declaration order was nearly the inverse of RESOURCE_MENU_ORDER, so the first consumer to map it would have silently rendered a backwards menu. Not included: buildUnifiedSettingsNavigation never applies its own unified.order and returns registry declaration order instead. Nothing user-visible today (the sidebar re-sorts before rendering) but navigation.test.ts pins the unsorted order as canonical, so fixing it means changing that test — worth doing deliberately rather than inside this PR.
1 parent 28f5e50 commit 107748d

28 files changed

Lines changed: 323 additions & 253 deletions

File tree

.claude/rules/sim-list-ordering.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
paths:
3+
- "apps/sim/app/**/*.tsx"
4+
- "apps/sim/ee/**/*.tsx"
5+
- "apps/sim/components/**/*.tsx"
6+
---
7+
8+
# List & Menu Ordering
9+
10+
**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.
11+
12+
This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.
13+
14+
## The rule
15+
16+
Before writing a list of items, find where the user sees those same items *first*. That surface owns the order; your list mirrors it.
17+
18+
| The list | Mirrors |
19+
| --- | --- |
20+
| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down |
21+
| A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom |
22+
| Settings tab strip, recently-deleted tabs | the **settings nav**, top-down |
23+
| A "New …" menu | the order those things appear once created |
24+
25+
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.
26+
27+
Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.
28+
29+
## Encode the order once
30+
31+
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.
32+
33+
```ts
34+
/** Top-down order for every menu listing resource families, mirroring the sidebar. */
35+
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
36+
'integration', 'task', 'table', 'file', 'filefolder',
37+
'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic',
38+
]
39+
40+
export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(a: T, b: T) {
41+
return RESOURCE_MENU_ORDER.indexOf(a.type) - RESOURCE_MENU_ORDER.indexOf(b.type)
42+
}
43+
```
44+
45+
Canonical instance: `app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx`, consumed by `useAvailableResources` and `ResourceMenuSections`.
46+
47+
## Render kinds in one pass, not one phase per kind
48+
49+
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.
50+
51+
```tsx
52+
// ✗ Bad — two phases; the trees always pin to the top
53+
<ResourceTreeSections sections={treeSections} />
54+
{groups.filter((g) => !FOLDERED.has(g.type)).map(renderFlat)}
55+
56+
// ✓ Good — one ordered pass; each entry picks its own rendering
57+
{entries.sort(byResourceMenuOrder).map((entry) =>
58+
sectionByType.has(entry.type) ? renderTree(entry) : renderFlat(entry)
59+
)}
60+
```
61+
62+
The same trap appears as "render the pinned ones, then the rest", "render enabled, then disabled", and "render the groups, then the loose items".
63+
64+
## When order may diverge
65+
66+
Only for reasons the user can perceive:
67+
68+
- **Search/filter results** rank by match quality — the whole point is that ranking beats position.
69+
- **User-controlled ordering** (drag-to-reorder, manual `sortOrder`) wins over any canonical order.
70+
- **Recency lists** ("Recent chats") order by time, which *is* the order the user reads them elsewhere.
71+
72+
"Grouped by which hook provides it", "alphabetical because it was easy", and "that's the order the array was built in" are not reasons.
73+
74+
## Reviewing
75+
76+
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.

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ Shareable *client* view-state (active tab/panel, filters, search query, paginati
378378

379379
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`.
380380

381+
## List & Menu Ordering
382+
383+
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.
384+
385+
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`.
386+
381387
## Styling
382388

383389
Use Tailwind only, no inline styles. Use `cn()` from `@sim/emcn` for conditional classes.

apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
4343
sideOffset={4}
4444
onCloseAutoFocus={(e) => e.preventDefault()}
4545
>
46-
{onCreateFile && (
47-
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateFile}>
48-
<Plus />
49-
New file
46+
{/* Upload, New folder, New file — the order the page header presents
47+
them once `orderHeaderActions` has pinned the primary action last. */}
48+
{onUploadFile && (
49+
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
50+
<Upload />
51+
Upload file
5052
</DropdownMenuItem>
5153
)}
5254
{onCreateFolder && (
@@ -55,10 +57,10 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
5557
New folder
5658
</DropdownMenuItem>
5759
)}
58-
{onUploadFile && (
59-
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
60-
<Upload />
61-
Upload file
60+
{onCreateFile && (
61+
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateFile}>
62+
<Plus />
63+
New file
6264
</DropdownMenuItem>
6365
)}
6466
</DropdownMenuContent>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 74 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
buildResourceFolderTree,
2828
type ResourceTreeNode,
2929
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
30-
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
30+
import {
31+
byResourceMenuOrder,
32+
getResourceConfig,
33+
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
3134
import {
3235
RESOURCE_TAB_ICON_BUTTON_CLASS,
3336
RESOURCE_TAB_ICON_CLASS,
@@ -297,7 +300,7 @@ export function useAvailableResources(
297300
],
298301
})
299302
}
300-
return groups.filter((g) => !excluded.has(g.type))
303+
return groups.filter((g) => !excluded.has(g.type)).sort(byResourceMenuOrder)
301304
}, [
302305
enabled,
303306
workflows,
@@ -414,12 +417,17 @@ interface FolderedSectionSpec {
414417
orderBySortOrder?: boolean
415418
}
416419

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

425433
/**
@@ -464,8 +472,11 @@ export function useResourceTreeSections({
464472
}, [groups, structureFolders])
465473
}
466474

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

478-
/** Renders {@link useResourceTreeSections} output as one submenu per family. */
479-
export function ResourceTreeSections({
489+
/**
490+
* Renders every resource family as one submenu, foldered and flat interleaved in
491+
* {@link RESOURCE_MENU_ORDER}. Rendering the two kinds in one pass is what lets a
492+
* foldered family (Tables) sit above a flat one (Logs) — emitting all the trees
493+
* and then all the flat families would pin every tree to the top regardless of the
494+
* canonical order.
495+
*/
496+
export function ResourceMenuSections({
480497
sections,
498+
groups,
481499
onSelect,
482500
subContentClassName,
483-
}: ResourceTreeSectionsProps) {
501+
}: ResourceMenuSectionsProps) {
502+
const sectionByType = new Map(sections.map((section) => [section.type, section]))
503+
const entries = groups
504+
.filter(({ type, items }) =>
505+
FOLDERED_RESOURCE_TYPES.has(type) ? sectionByType.has(type) : items.length > 0
506+
)
507+
.sort(byResourceMenuOrder)
508+
484509
return (
485510
<>
486-
{sections.map((section) => {
487-
const config = getResourceConfig(section.type)
488-
const SectionIcon = config.icon
511+
{entries.map(({ type, items }) => {
512+
const config = getResourceConfig(type)
513+
const Icon = config.icon
514+
const section = sectionByType.get(type)
515+
516+
// Browser and terminal each have one top-level panel — a flat launcher
517+
// here creates inner tabs when that panel already exists.
518+
if (!section && (type === 'browser' || type === 'terminal')) {
519+
const item = items[0]
520+
return (
521+
<DropdownMenuItem
522+
key={type}
523+
onClick={() => onSelect({ type, id: item.id, title: item.name })}
524+
>
525+
<Icon className='size-[14px]' />
526+
<span>{config.label}</span>
527+
</DropdownMenuItem>
528+
)
529+
}
530+
489531
return (
490-
<DropdownMenuSub key={section.type}>
532+
<DropdownMenuSub key={type}>
491533
<DropdownMenuSubTrigger>
492-
<SectionIcon className='size-[14px]' />
534+
<Icon className='size-[14px]' />
493535
<span>{config.label}</span>
494536
</DropdownMenuSubTrigger>
495537
<DropdownMenuSubContent className={subContentClassName}>
496-
<ResourceFolderTreeItems
497-
nodes={section.nodes}
498-
type={section.type}
499-
folderType={section.folderType}
500-
onSelect={onSelect}
501-
/>
538+
{section ? (
539+
<ResourceFolderTreeItems
540+
nodes={section.nodes}
541+
type={section.type}
542+
folderType={section.folderType}
543+
onSelect={onSelect}
544+
/>
545+
) : (
546+
items.map((item) => (
547+
<DropdownMenuItem
548+
key={item.id}
549+
onClick={() => onSelect({ type, id: item.id, title: item.name })}
550+
>
551+
{config.renderDropdownItem({ item })}
552+
</DropdownMenuItem>
553+
))
554+
)}
502555
</DropdownMenuSubContent>
503556
</DropdownMenuSub>
504557
)
@@ -650,47 +703,7 @@ export function AddResourceDropdown({
650703
</div>
651704
)
652705
) : (
653-
<>
654-
<ResourceTreeSections sections={treeSections} onSelect={select} />
655-
{available.map(({ type, items }) => {
656-
if (FOLDERED_RESOURCE_TYPES.has(type)) return null
657-
if (items.length === 0) return null
658-
const config = getResourceConfig(type)
659-
const Icon = config.icon
660-
// Browser and terminal each have one top-level panel — flat
661-
// launchers here create inner tabs when that panel exists.
662-
if (type === 'browser' || type === 'terminal') {
663-
const item = items[0]
664-
return (
665-
<DropdownMenuItem
666-
key={type}
667-
onClick={() => select({ type, id: item.id, title: item.name })}
668-
>
669-
<Icon className='size-[14px]' />
670-
<span>{config.label}</span>
671-
</DropdownMenuItem>
672-
)
673-
}
674-
return (
675-
<DropdownMenuSub key={type}>
676-
<DropdownMenuSubTrigger>
677-
<Icon className='size-[14px]' />
678-
<span>{config.label}</span>
679-
</DropdownMenuSubTrigger>
680-
<DropdownMenuSubContent>
681-
{items.map((item) => (
682-
<DropdownMenuItem
683-
key={item.id}
684-
onClick={() => select({ type, id: item.id, title: item.name })}
685-
>
686-
{config.renderDropdownItem({ item })}
687-
</DropdownMenuItem>
688-
))}
689-
</DropdownMenuSubContent>
690-
</DropdownMenuSub>
691-
)
692-
})}
693-
</>
706+
<ResourceMenuSections sections={treeSections} groups={available} onSelect={select} />
694707
)}
695708
</div>
696709
</DropdownMenuContent>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export {
22
AddResourceDropdown,
33
FOLDERED_RESOURCE_TYPES,
44
ResourceFolderTreeItems,
5-
ResourceTreeSections,
5+
ResourceMenuSections,
66
useAvailableResources,
77
useResourceTreeSections,
88
} from './add-resource-dropdown'

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ export {
55
getResourceConfig,
66
invalidateResourceQueries,
77
RESOURCE_REGISTRY,
8-
RESOURCE_TYPES,
98
} from './resource-registry'
109
export { ResourceTabs } from './resource-tabs'
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export type { ResourceTypeConfig } from './resource-registry'
22
export {
3+
byResourceMenuOrder,
34
getResourceConfig,
45
invalidateResourceQueries,
6+
RESOURCE_MENU_ORDER,
57
RESOURCE_REGISTRY,
6-
RESOURCE_TYPES,
78
} from './resource-registry'

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,36 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
222222
},
223223
} as const
224224

225-
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
225+
/**
226+
* Top-down order for every menu that lists resource families, mirroring the
227+
* workspace sidebar so a user reads the same sequence in both places. The two
228+
* desktop-only panels trail the workspace resources, matching where they surface
229+
* in the app. `folder`/`filefolder` never render as their own entry — they feed
230+
* their family's folder tree — but are ordered beside it so a menu that ever does
231+
* surface them lands in the right place.
232+
*/
233+
export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [
234+
'integration',
235+
'task',
236+
'table',
237+
'file',
238+
'filefolder',
239+
'knowledgebase',
240+
'log',
241+
'workflow',
242+
'folder',
243+
'browser',
244+
'terminal',
245+
'generic',
246+
]
247+
248+
/** Sorts anything keyed by resource type into {@link RESOURCE_MENU_ORDER}. */
249+
export function byResourceMenuOrder<T extends { type: MothershipResourceType }>(
250+
a: T,
251+
b: T
252+
): number {
253+
return RESOURCE_MENU_ORDER.indexOf(a.type) - RESOURCE_MENU_ORDER.indexOf(b.type)
254+
}
226255

227256
export function getResourceConfig(type: MothershipResourceType): ResourceTypeConfig {
228257
return RESOURCE_REGISTRY[type]

0 commit comments

Comments
 (0)