From 9da559e8a86b086ab74503865346142de4775673 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 13:54:19 -0700 Subject: [PATCH 1/2] improvement(ui): consolidate the disclosure chevron and order resource menus like the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .claude/rules/sim-list-ordering.md | 76 ++++++++++ CLAUDE.md | 6 + .../add-resource-dropdown.tsx | 135 ++++++++++-------- .../components/add-resource-dropdown/index.ts | 2 +- .../components/resource-registry/index.ts | 2 + .../resource-registry/resource-registry.tsx | 31 ++++ .../plus-menu-dropdown/plus-menu-dropdown.tsx | 37 +---- .../collapsed-sidebar-menu.tsx | 16 +-- .../components/file-list/file-list.tsx | 58 +++----- .../settings-sidebar/settings-sidebar.tsx | 18 ++- .../sidebar-section/sidebar-section.tsx | 16 +-- .../components/folder-item/folder-item.tsx | 22 +-- .../components/settings/settings-sidebar.tsx | 11 +- .../chip-date-picker/chip-date-picker.tsx | 7 +- .../chip-dropdown/chip-dropdown.tsx | 6 +- .../components/chip-select/chip-select.tsx | 6 +- .../emcn/src/components/chip/chip-chevron.tsx | 9 +- .../emcn/src/components/chip/chip-chrome.ts | 14 ++ packages/emcn/src/components/index.ts | 2 + 19 files changed, 266 insertions(+), 208 deletions(-) create mode 100644 .claude/rules/sim-list-ordering.md diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md new file mode 100644 index 00000000000..2966eb4a1a6 --- /dev/null +++ b/.claude/rules/sim-list-ordering.md @@ -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(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 + +{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. diff --git a/CLAUDE.md b/CLAUDE.md index 1a1e4671737..fc63380d153 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index d1cdc78b561..d3f76ca1ae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -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, @@ -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, @@ -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 }, ] /** @@ -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 @@ -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 ( + onSelect({ type, id: item.id, title: item.name })} + > + + {config.label} + + ) + } + return ( - + - + {config.label} - + {section ? ( + + ) : ( + items.map((item) => ( + onSelect({ type, id: item.id, title: item.name })} + > + {config.renderDropdownItem({ item })} + + )) + )} ) @@ -650,47 +703,7 @@ export function AddResourceDropdown({ ) ) : ( - <> - - {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 ( - select({ type, id: item.id, title: item.name })} - > - - {config.label} - - ) - } - return ( - - - - {config.label} - - - {items.map((item) => ( - select({ type, id: item.id, title: item.name })} - > - {config.renderDropdownItem({ item })} - - ))} - - - ) - })} - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index 337b05909ae..d419eca23be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -2,7 +2,7 @@ export { AddResourceDropdown, FOLDERED_RESOURCE_TYPES, ResourceFolderTreeItems, - ResourceTreeSections, + ResourceMenuSections, useAvailableResources, useResourceTreeSections, } from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts index bd16abae17a..ecdfa1cd258 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts @@ -1,7 +1,9 @@ export type { ResourceTypeConfig } from './resource-registry' export { + byResourceMenuOrder, getResourceConfig, invalidateResourceQueries, + RESOURCE_MENU_ORDER, RESOURCE_REGISTRY, RESOURCE_TYPES, } from './resource-registry' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 5a4793713ad..999473f88af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -224,6 +224,37 @@ export const RESOURCE_REGISTRY: Record( + 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] } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 67ad246f767..39f9c4b0ae2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -5,16 +5,11 @@ import { cn, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuSearchInput, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' import { - FOLDERED_RESOURCE_TYPES, - ResourceTreeSections, + ResourceMenuSections, useAvailableResources, useResourceTreeSections, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' @@ -305,38 +300,12 @@ export const PlusMenuDropdown = React.memo( {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */} {/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's menu Collection, or FocusScope restores focus to the content root. */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index d29e742c0a0..29bc6910540 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' -import { Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons' +import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons' import Link from 'next/link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' @@ -62,19 +62,7 @@ function fileFlyoutEntries( } const FILE_FLYOUT_ICON = ( - +