improvement(menus): band by what the action acts on, not by verb (#6994)

Replaces "at most one separator" with the test that actually earns a
rule: does the next group stop acting on the thing the user clicked.

Most row menus have only the destructive transition and keep their one
rule. Three menus have a second, and now show it — the logs row menu
between this log and the page's filters, and the table row and column
menus where they stop acting on the clicked cell or column and start
creating siblings. Count follows content rather than a fixed number.

The workflow panel menu had six items across four scopes and no
separator at all, so "Delete workflow" sat flush against "Duplicate
workflow"; it now carries the destructive rule every other menu has.

Two label fixes: the logs menu wrote 'Retrying...' with ASCII dots two
lines above 'Stopping…' with the character, and "Import CSV…" was the
only ellipsis anywhere in the workspace menu surface while the same
action reads "Import CSV" in the list menu.
This commit is contained in:
Waleed
2026-08-22 15:50:44 -07:00
committed by GitHub
parent 37590bdf73
commit b1759f3545
7 changed files with 61 additions and 24 deletions
+31 -21
View File
@@ -26,38 +26,48 @@ 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
## Grouping: a rule marks a change in what the action acts on
Order is governed above. **Separators are governed here** — and the answer is: use at most one.
Order is governed above. **Separators are governed here.**
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.
A `DropdownMenuSeparator` earns its place when the next group stops acting on the thing the user
clicked. That is the whole test — one question, asked the same way in every menu:
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.
| The group | Gets a rule before it |
| --- | --- |
| Acts on the clicked item (open, rename, duplicate, export, copy, edit, pin, run) | no — this is the body of the menu |
| Acts on **something else** — the page's filters or view, or a newly created sibling | yes |
| **Destroys or detaches** it (delete, leave, close, hide, remove) | yes |
Most row menus only ever have the one transition, so they carry one rule, immediately before
`Delete`. A menu that also filters the page or inserts siblings carries two. Nothing carries
more, because there is no third thing a menu acts on.
Do **not** band by verb. "Navigation", "status", "edit", "copy" are categories of *what the verb
is*, not of *what it touches*, and the user meets no such taxonomy anywhere else — every toolbar
in the app is a flat `gap-1` chip row with no dividers. Menus banded that way put the same action
in different groups depending on which siblings happened to be visible.
The consequential group trails in almost every menu. It leads in exactly one: the **logs row
menu**, where `Retry` and `Cancel Run` act on the run itself and are the primary actions on a
failure, so they sit on top with the rule beneath them. Ordering follows the surface (see "The
rule" above); the separator fences whichever end that group occupies.
A group whose items 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
// ✓ Good — one rule, where the menu stops acting on the workflow
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.
**Worked examples.** The logs row menu carries two: `Retry, Cancel Run │ Copy Run ID, Copy Link,
Open Workflow, Open Snapshot │ Filter by Workflow, Clear Filters` — the run, then this log, then
the page. The table row and column menus carry two: the rule before `Insert row above` /
`Insert column left` is where the menu stops acting on the clicked cell and starts creating
siblings. Every other row menu in the app has only the destructive transition, so it carries one.
**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
+1 -1
View File
@@ -388,7 +388,7 @@ A list orders itself the way the user already reads the same things somewhere el
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`.
**Grouping**: a `DropdownMenuSeparator` marks a change in WHAT the action acts on — the clicked item (no rule), something else like the page's filters or a new sibling (rule), or destroying it (rule). Most row menus have only the destructive transition and carry one rule before Delete/Leave/Close/Hide; menus that also filter the page or insert siblings carry two. Never band by verb (navigation/status/edit/copy) — the toolbars are flat, so that taxonomy exists nowhere else. 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
@@ -105,7 +105,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
{isRetryable && (
<DropdownMenuItem onSelect={onRetryExecution} disabled={isRetryPending}>
<Redo />
{isRetryPending ? 'Retrying...' : 'Retry'}
{isRetryPending ? 'Retrying' : 'Retry'}
</DropdownMenuItem>
)}
{showCancelAction && (
@@ -131,6 +131,9 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<Eye />
Open Snapshot
</DropdownMenuItem>
{/* Stops acting on this run and starts acting on the page's filters the
second of the two scope changes this menu has. */}
{(!isFilteredByThisWorkflow || hasActiveFilters) && <DropdownMenuSeparator />}
{!isFilteredByThisWorkflow && (
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onToggleWorkflowFilter}>
<ListFilter />
@@ -141,6 +141,22 @@ export function ContextMenu({
? `Add ${addToChatRows.toLocaleString()} rows to Chat`
: 'Add row to Chat'
/**
* Whether anything renders above the sibling-creating inserts. Each term is the
* exact render condition of its item, so the rule can never lead the menu.
*
* @see `.claude/rules/sim-list-ordering.md` a rule marks a change in what the
* action acts on.
*/
const hasCellScopedActions =
Boolean(onAddToChat) ||
Boolean(contextMenu.columnName && canEditCell) ||
Boolean(onFilterByCellValue) ||
Boolean(hasWorkflowColumns && onRunWorkflows) ||
Boolean(hasWorkflowColumns && onRefreshWorkflows) ||
Boolean(hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0) ||
Boolean(canViewExecution && onViewExecution)
return (
<DropdownMenu
open={contextMenu.isOpen}
@@ -219,6 +235,9 @@ export function ContextMenu({
View execution
</DropdownMenuItem>
)}
{/* Stops acting on the clicked cell/row and starts creating siblings. Every
item above is conditional, so the rule is guarded on all of them. */}
{hasCellScopedActions && <DropdownMenuSeparator />}
<DropdownMenuItem disabled={disableInsert} onSelect={onInsertAbove}>
<ArrowUp />
Insert row above
@@ -238,6 +238,9 @@ export function ColumnOptionsMenu({
{isPinned ? 'Unpin column' : 'Pin column'}
</DropdownMenuItem>
)}
{/* Stops acting on this column and starts creating siblings `Edit column`
above is unconditional, so the rule is always backed. */}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onInsertLeft(column.key)}>
<ArrowLeft />
Insert column left
@@ -111,7 +111,7 @@ export function TableContextMenu({
{onImportCsv && (
<DropdownMenuItem disabled={disableImport} onSelect={onImportCsv}>
<Upload />
Import CSV
Import CSV
</DropdownMenuItem>
)}
{onExportCsv && (
@@ -10,6 +10,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Duplicate,
Layout,
@@ -742,6 +743,7 @@ export const Panel = memo(function Panel() {
<Duplicate />
Duplicate workflow
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setIsDeleteModalOpen(true)