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
65 changes: 65 additions & 0 deletions .claude/rules/sim-list-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,71 @@ Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export

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

## Grouping: one rule, against the consequential group

Order is governed above. **Separators are governed here** — and the answer is: use at most one.

Put a single `DropdownMenuSeparator` against the **consequential group** — the actions that
delete, detach, or change a run — and nowhere else. Everything on the other side of it runs
uninterrupted in toolbar-mirroring order.

That group trails in almost every menu, so in practice the rule reads "one rule immediately
before Delete / Leave / Close / Hide". It leads in exactly one place: the **logs row menu**,
where `Retry` and `Cancel Run` are the primary actions on a failed run and sit at the top, with
the rule beneath them. Ordering follows the surface (see "The rule" above); the separator simply
fences whichever end the consequential group occupies. A menu whose consequential actions are
merely *disabled* still gets no extra rule — `disabled` is not a group.

```tsx
// ✗ Bad — four semantic bands the user meets nowhere else
Open in new tab │─── Rename, Lock │─── Duplicate, Export │─── Delete

// ✓ Good — one rule, isolating the irreversible action
Open in new tab, Rename, Lock, Duplicate, Export │─── Delete
```

**Why one.** No toolbar in this app renders a divider — every header is a flat
`HEADER_ACTION_CLUSTER` (`gap-1`) chip row and every bulk action bar a flat `gap-[5px]` run. A
menu banded into navigation / status / edit / copy / destructive therefore teaches a taxonomy
that appears on no other surface, and because each band is conditional, the same action lands in
a different group depending on which sibling items happen to be visible. The one thing a rule
genuinely buys is a stop before the action you cannot undo.

A second rule is justified only when a menu mixes genuinely different *scopes* — cell-level and
table-level actions in one menu, say — not different verbs.

**The one standing exception: menus that emulate a native menu.** The text-editor menu
(`editor-context-menu.tsx`), the terminal menu (`terminal-context-menu.tsx`), and the browser
page menu (`browser-session.tsx`) each mirror the OS menu the user already knows — clipboard
banding (`Cut · Copy · Paste │ Select all`) is a convention every text field on their machine
teaches them. These keep their native banding, and that is the *same* principle as the ordering
rule above: mirror the surface the user already reads. The test is whether a real menu outside
Sim taught them the grouping. Our own resource, row, and action menus have no such precedent —
the toolbars they mirror are flat — so they take the single rule.

**Both sides of every rule must be guaranteed non-empty.** Write the separator's guard out of
the *exact* render conditions of the items around it, never a looser approximation:

```tsx
// ✗ Bad — `showLeave` alone, while the Leave item needs `showLeave && onLeave`.
// A caller passing showLeave from a permission check with a conditional
// onLeave renders a trailing rule under the last item.
{hasActionsAbove && (showLeave || showDelete) && <DropdownMenuSeparator />}

// ✓ Good — each term is the item's own condition, verbatim
const hasDestructiveSection = (showLeave && onLeave) || showDelete
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
```

This is the failure that put a dangling rule at the bottom of the logs row menu, where two
unconditional separators sat above conditional items.

**Do not add a prop to move a rule.** The shared workflow context menu grew
`groupNonDestructiveActions` and `separateNavigationAction` for this; between them they moved one
separator for one caller, four of six branches were unreachable, and `separateNavigationAction`
had no observable effect anywhere in the repo. Both are gone. A menu that wants different
grouping wants the standard grouping.

## 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.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,9 @@ Co-locate a `search-params.ts` per feature exporting the parser map (single sour

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

**Grouping**: at most ONE `DropdownMenuSeparator` per menu, fencing the consequential group — immediately before Delete/Leave/Close/Hide in almost every menu, and immediately after Retry/Cancel Run in the logs row menu, where those lead. No toolbar in the app renders a divider, so multi-band menus teach a taxonomy that exists on no other surface. Build each separator's guard from the EXACT render conditions of the items on both sides — a looser guard is what leaves a dangling rule when its group is conditional. Never add a prop to move a rule. Full rule in `.claude/rules/sim-list-ordering.md`.

## Styling

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ export const FolderContextMenu = memo(function FolderContextMenu({
)}
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
}: FileRowContextMenuProps) {
const isMultiSelect = selectedCount > 1

/**
* Everything that can render above `Delete`: Open/Pin need a single selection,
* Download needs its handler, and the edit trio needs `canEdit` — so a multi-select
* with no download and only a move target leaves `Move to` alone above the rule.
*
* @see `.claude/rules/sim-list-ordering.md` — one rule, before the destructive group.
*/
const hasActionsAboveDestructive =
!isMultiSelect || !!onDownload || (!!onMove && !!moveOptions && moveOptions.length > 0)

return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -91,7 +101,6 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
)}
{canEdit && (
<>
<DropdownMenuSeparator />
{!isMultiSelect && (
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Expand Down Expand Up @@ -120,6 +129,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onDelete}>
<Trash />
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
{onUploadFile && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
<Upload />
Upload file
Upload
</DropdownMenuItem>
)}
{onCreateFolder && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,7 @@ export function BrowserTabStrip({
onOpenInNewTab={openTabInExternalBrowser}
openInNewTabLabel='Open in External Browser'
openInNewTabPosition='last'
separateNavigationAction
showOpenInNewTab={Boolean(contextTab?.url && contextTab.url !== 'about:blank')}
groupNonDestructiveActions
onTogglePin={
contextTab ? () => onSetTabPinned(contextTab.tabId, !contextTab.pinned) : undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface ChunkContextMenuProps {

/**
* Context menu for chunks table.
* Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
* Shows chunk actions when right-clicking a row, or "New chunk" when right-clicking empty space.
* Supports batch operations when multiple chunks are selected.
*/
export function ChunkContextMenu({
Expand Down Expand Up @@ -71,6 +71,7 @@ export function ChunkContextMenu({
const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection

return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
Expand Down Expand Up @@ -102,11 +103,6 @@ export function ChunkContextMenu({
Open in new tab
</DropdownMenuItem>
)}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}

{!isMultiSelect && onEdit && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
<Pencil />
Expand All @@ -119,18 +115,14 @@ export function ChunkContextMenu({
Copy content
</DropdownMenuItem>
)}
{hasEditSection && (hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}

{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
</DropdownMenuItem>
)}

{hasStateSection && hasDestructiveSection && <DropdownMenuSeparator />}
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Expand All @@ -142,7 +134,7 @@ export function ChunkContextMenu({
onAddChunk && (
<DropdownMenuItem disabled={disableAddChunk} onSelect={onAddChunk}>
<Plus />
Create chunk
New chunk
</DropdownMenuItem>
)
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function DocumentContextMenu({
const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection

return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
Expand Down Expand Up @@ -108,11 +109,6 @@ export function DocumentContextMenu({
Open source
</DropdownMenuItem>
)}
{hasNavigationSection &&
(hasEditSection || hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}

{!isMultiSelect && onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
<Pencil />
Expand All @@ -125,18 +121,14 @@ export function DocumentContextMenu({
Tags
</DropdownMenuItem>
)}
{hasEditSection && (hasStateSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}

{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
</DropdownMenuItem>
)}

{hasStateSection && hasDestructiveSection && <DropdownMenuSeparator />}
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Expand All @@ -148,7 +140,7 @@ export function DocumentContextMenu({
onAddDocument && (
<DropdownMenuItem disabled={disableAddDocument} onSelect={onAddDocument}>
<Plus />
Add document
New documents
</DropdownMenuItem>
)
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
const hasMoveSection = !disableEdit && !!onMove && !!moveOptions && moveOptions.length > 0
const hasEditSection = (showEdit && !!onEdit) || hasMoveSection
const hasDestructiveSection = showDelete && !!onDelete
const hasActionsAboveDestructive = hasNavigationSection || hasInfoSection || hasEditSection

return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
Expand Down Expand Up @@ -104,10 +105,6 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
Open in new tab
</DropdownMenuItem>
)}
{hasNavigationSection && (hasInfoSection || hasEditSection || hasDestructiveSection) && (
<DropdownMenuSeparator />
)}

{showViewTags && onViewTags && (
<DropdownMenuItem onSelect={onViewTags}>
<TagIcon />
Expand All @@ -126,8 +123,6 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <DropdownMenuSeparator />}

{showEdit && onEdit && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
<Pencil />
Expand All @@ -147,7 +142,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
</DropdownMenuSub>
)}

{hasEditSection && hasDestructiveSection && <DropdownMenuSeparator />}
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
{showDelete && onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,18 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isRetryable && (
<>
<DropdownMenuItem onSelect={onRetryExecution} disabled={isRetryPending}>
<Redo />
{isRetryPending ? 'Retrying...' : 'Retry'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
<DropdownMenuItem onSelect={onRetryExecution} disabled={isRetryPending}>
<Redo />
{isRetryPending ? 'Retrying...' : 'Retry'}
</DropdownMenuItem>
)}
{showCancelAction && (
<>
<DropdownMenuItem onSelect={onCancelExecution} disabled={isStopping}>
<X />
{isStopping ? 'Stopping…' : 'Cancel Run'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
<DropdownMenuItem onSelect={onCancelExecution} disabled={isStopping}>
<X />
{isStopping ? 'Stopping…' : 'Cancel Run'}
</DropdownMenuItem>
)}
{(isRetryable || showCancelAction) && <DropdownMenuSeparator />}
Comment thread
waleedlatif1 marked this conversation as resolved.
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onCopyExecutionId}>
<Duplicate />
Copy Run ID
Expand All @@ -128,8 +123,6 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<Link />
Copy Link
</DropdownMenuItem>

<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasOpenableWorkflow} onSelect={onOpenWorkflow}>
<SquareArrowUpRight />
Open Workflow
Expand All @@ -138,8 +131,6 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<Eye />
Open Snapshot
</DropdownMenuItem>

<DropdownMenuSeparator />
{!isFilteredByThisWorkflow && (
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onToggleWorkflowFilter}>
<ListFilter />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,10 @@ export function ContextMenu({
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddToChat && (
<>
<DropdownMenuItem onSelect={onAddToChat}>
<Blimp />
{addToChatLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
<DropdownMenuItem onSelect={onAddToChat}>
<Blimp />
{addToChatLabel}
</DropdownMenuItem>
)}
{contextMenu.columnName && canEditCell && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEditCell}>
Expand Down
Loading
Loading