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]/files/components/files-list-context-menu/files-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx index 9d6308e761a..1fbe5068162 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx @@ -43,10 +43,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {onCreateFile && ( - - - New file + {/* Upload, New folder, New file — the order the page header presents + them once `orderHeaderActions` has pinned the primary action last. */} + {onUploadFile && ( + + + Upload file )} {onCreateFolder && ( @@ -55,10 +57,10 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ New folder )} - {onUploadFile && ( - - - Upload file + {onCreateFile && ( + + + New file )} 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/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts index 47d3ad87474..3eafad11021 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts @@ -5,6 +5,5 @@ export { getResourceConfig, invalidateResourceQueries, RESOURCE_REGISTRY, - RESOURCE_TYPES, } from './resource-registry' export { ResourceTabs } from './resource-tabs' 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..e8ae4e4ba60 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,8 @@ 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..22750f369a5 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 @@ -222,7 +222,36 @@ 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]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx index ee95e23bc19..3c7b2e35804 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx @@ -49,18 +49,20 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {onAddKnowledgeBase && ( - - - Add knowledge base - - )} + {/* New folder, New base — the order the page header presents them once + `orderHeaderActions` has pinned the primary action last. */} {onAddFolder && ( New folder )} + {onAddKnowledgeBase && ( + + + New base + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 39db4ff9265..ef7cb42baec 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -114,14 +114,15 @@ interface RestoredResourceEntry { displayIndex: number } +/** Labels for {@link RECENTLY_DELETED_TABS}, which owns the order. */ const TABS: { id: ResourceType; label: string }[] = [ { id: 'all', label: 'All' }, - { id: 'workflow', label: 'Workflows' }, - { id: 'folder', label: 'Folders' }, + { id: 'chat', label: 'Chats' }, { id: 'table', label: 'Tables' }, - { id: 'knowledge', label: 'Knowledge Bases' }, { id: 'file', label: 'Files' }, - { id: 'chat', label: 'Chats' }, + { id: 'knowledge', label: 'Knowledge Bases' }, + { id: 'workflow', label: 'Workflows' }, + { id: 'folder', label: 'Folders' }, ] const TYPE_LABEL: Record, string> = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index 6379d8f145e..62ae0141460 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,15 +1,20 @@ import { parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' -/** Selectable resource-type tabs in the Recently Deleted view. */ +/** + * Selectable resource-type tabs in the Recently Deleted view, after the default + * `all`: the sidebar's top-down order, so the tabs read the way the user already + * reads these resources. `TABS` in `recently-deleted.tsx` labels this same list — + * keep the two in step. + */ export const RECENTLY_DELETED_TABS = [ 'all', - 'workflow', - 'folder', + 'chat', 'table', - 'knowledge', 'file', - 'chat', + 'knowledge', + 'workflow', + 'folder', ] as const export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number] diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index e4ca0b262a8..dd89bcd56a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -175,13 +175,10 @@ export function ContextMenu({ Edit cell )} - {canViewExecution && onViewExecution && ( - - - View execution - - )} - {/* Not gated on `disableEdit`: these write only workflow-output columns, + {/* Run, Re-run, Stop, then View execution — the order the action bar + presents the same four, so the user reads one sequence in both. + + Not gated on `disableEdit`: these write only workflow-output columns, which the update lock exempts, and Stop is a cancel rather than a write. Their handlers are already withheld without edit permission. */} {hasWorkflowColumns && onRunWorkflows && ( @@ -202,6 +199,12 @@ export function ContextMenu({ {stopLabel} )} + {canViewExecution && onViewExecution && ( + + + View execution + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx index 1454dec9146..c9babea6f76 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx @@ -54,10 +54,12 @@ export function TablesListContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {onCreateTable && ( - - - Create table + {/* Import CSV, New folder, New table — the order the page header presents + them once `orderHeaderActions` has pinned the primary action last. */} + {onUploadCsv && ( + + + Import CSV )} {onCreateFolder && ( @@ -66,10 +68,10 @@ export function TablesListContextMenu({ New folder )} - {onUploadCsv && ( - - - Import CSV + {onCreateTable && ( + + + New table )} 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 = ( - +