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
4 changes: 2 additions & 2 deletions apps/desktop/src/main/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ describe('buildMenuTemplate', () => {
])
})

it('keeps Help limited to documentation and system status', () => {
it('keeps Help limited to documentation and Sim status', () => {
const help = submenu(buildMenuTemplate(makeDeps()), 'Help')
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'System Status'])
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status'])
})

it('never exposes developer tools in the application menu', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()),
},
{
label: 'System Status',
label: 'Sim Status',
click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,10 +686,13 @@ export function AddResourceDropdown({
filtered.length > 0 ? (
filtered.map(({ type, item }, index) => {
const config = getResourceConfig(type)
/* The search box keeps focus, so rows never take DOM focus and the menu's
own `focus:` highlight never fires — `activeIndex` is this list's
cursor, so it paints the hover surface rather than the selected one. */
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
className={cn(index === activeIndex && 'bg-[var(--surface-hover)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => select({ type, id: item.id, title: item.name })}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,9 @@ export const PlusMenuDropdown = React.memo(
handleSelect({ type, id: item.id, title: item.name })
}}
className={cn(
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
isActive && 'bg-[var(--surface-active)]'
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
>
{config.renderDropdownItem({ item })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,9 @@ export const SkillsMenuDropdown = React.memo(
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(target)}
className={cn(
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
isActive && 'bg-[var(--surface-active)]'
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
>
{target.kind === 'skill' ? <AgentSkillsIcon /> : <McpIcon />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,8 @@ export function CollapsedFileFolderItems({
{fileFlyoutEntries(nodes, rootFiles ?? []).map((entry) => {
if (entry.kind === 'file') {
return (
<DropdownMenuItem key={entry.id} asChild>
<Link
href={`/workspace/${workspaceId}/files/${entry.file.id}`}
className={cn(currentFileId === entry.file.id && 'bg-[var(--surface-active)]')}
>
<DropdownMenuItem key={entry.id} asChild active={currentFileId === entry.file.id}>
<Link href={`/workspace/${workspaceId}/files/${entry.file.id}`}>
{FILE_FLYOUT_ICON}
<span className='truncate'>{entry.name}</span>
</Link>
Expand All @@ -107,7 +104,7 @@ export function CollapsedFileFolderItems({

return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-hover)] data-[state=open]:bg-[var(--surface-hover)]'>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
Expand Down Expand Up @@ -265,7 +262,7 @@ export function CollapsedChatFlyoutItem({
return (
<DropdownMenuItem
asChild
className={cn((isCurrentRoute || isMenuOpen) && 'bg-[var(--surface-active)]')}
active={isCurrentRoute || isMenuOpen}
action={
showActions ? (
<DropdownMenuItemAction
Expand Down Expand Up @@ -342,7 +339,7 @@ export function CollapsedWorkflowFlyoutItem({
return (
<DropdownMenuItem
asChild
className={cn((isCurrentRoute || actionsOpen) && 'bg-[var(--surface-active)]')}
active={isCurrentRoute || actionsOpen}
action={
hasActions ? (
<DropdownMenuSub
Expand Down Expand Up @@ -460,7 +457,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {

return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)]'>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
Expand Down
56 changes: 49 additions & 7 deletions packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,33 @@ const ANIMATION_CLASSES =
const MENU_ROW_HEIGHT_CLASS = 'h-[28px]'
const MENU_ROW_RADIUS_CLASS = 'rounded-lg'

/**
* Rows settle instantly, matching the sidebar (`[&_.group.cursor-pointer]:duration-0`
* on its `aside`). A menu is walked, not read: at the default 150ms the fill lags a
* cursor dragged down the list and two or three rows are mid-fade at once, which reads
* as smear rather than as one row following the pointer. `transition-colors` stays so a
* consumer can opt a row back into a duration.
*/
const MENU_ROW_TRANSITION_CLASS = 'transition-colors duration-0'

/**
* The two row surfaces, mirroring `chipHoverSurfaceClass` / `chipActiveSurfaceClass`
* — mutually exclusive, so a selected row holds its surface through hover instead of
* dimming to the hover fill under the cursor.
*
* Highlight is keyed off `focus:`, not `hover:`: Radix moves DOM focus to the row on
* pointer-move, so one selector covers both the pointer and the arrow-key cursor.
* Rows previously highlighted to `--surface-active` — the *selected* surface — so a
* hovered row looked selected and a menu appeared to have two selections at once. The
* `group-*` variants are inert outside the `action` layout, which is the only place a
* `group/dropdownitem` ancestor exists.
*/
const MENU_ROW_HIGHLIGHT_CLASS =
'focus:bg-[var(--surface-hover)] group-focus-within/dropdownitem:bg-[var(--surface-hover)] group-hover/dropdownitem:bg-[var(--surface-hover)]'
/** @see {@link MENU_ROW_HIGHLIGHT_CLASS} — the selected half of the same pair. */
const MENU_ROW_SELECTED_CLASS =
'bg-[var(--surface-active)] focus:bg-[var(--surface-active)] group-focus-within/dropdownitem:bg-[var(--surface-active)] group-hover/dropdownitem:bg-[var(--surface-active)]'

/**
* Rows are a fixed height, so a label that wraps overflows its row and paints
* over its neighbours instead of growing the row. Every row is therefore held
Expand Down Expand Up @@ -152,7 +179,10 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
`flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`,
/* An open submenu keeps its trigger on the selected surface — including while
the pointer is on it, so walking into the submenu doesn't drop the trigger
back to the hover fill. */
`flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none ${MENU_ROW_TRANSITION_CLASS} ${MENU_ROW_HIGHLIGHT_CLASS} data-[state=open]:bg-[var(--surface-active)] data-[state=open]:focus:bg-[var(--surface-active)] ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`,
inset && 'pl-7',
className
)}
Expand Down Expand Up @@ -214,29 +244,41 @@ const DropdownMenuContent = React.forwardRef<
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName

const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`
const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center gap-2 ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-none ${MENU_ROW_TRANSITION_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`

const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
/**
* Renders the row as selected — the current route, the checked value, the row
* whose own menu is open. Selected is a state the row *holds*, so it keeps
* `--surface-active` through hover rather than dimming to the hover fill.
*
* Not for a pointer/keyboard cursor: that is the row highlight, which the row
* already paints on its own. A menu that marks its cursor row `active` puts two
* selections on screen.
*/
active?: boolean
/**
* Optional inline action rendered on the right edge of the item — e.g. a
* "more" icon button. Reveals on hover/focus of the row, and the row stays
* highlighted while the cursor is over the action.
*/
action?: React.ReactNode
}
>(({ className, inset, action, asChild, children, ...props }, ref) => {
>(({ className, inset, active, action, asChild, children, ...props }, ref) => {
const content = asChild ? children : withEllipsizedLabel(children)
const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS
if (action) {
return (
<div className='group/dropdownitem relative'>
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
DROPDOWN_MENU_ITEM_BASE_CLASSES,
'pr-[28px] group-focus-within/dropdownitem:bg-[var(--surface-active)] group-hover/dropdownitem:bg-[var(--surface-active)]',
stateClasses,
'pr-[28px]',
inset && 'pl-7',
className
)}
Expand All @@ -254,7 +296,7 @@ const DropdownMenuItem = React.forwardRef<
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(DROPDOWN_MENU_ITEM_BASE_CLASSES, inset && 'pl-7', className)}
className={cn(DROPDOWN_MENU_ITEM_BASE_CLASSES, stateClasses, inset && 'pl-7', className)}
asChild={asChild}
{...props}
>
Expand Down Expand Up @@ -301,7 +343,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none ${MENU_ROW_TRANSITION_CLASS} ${MENU_ROW_HIGHLIGHT_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
className
)}
checked={checked}
Expand All @@ -324,7 +366,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
`relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${MENU_ROW_RADIUS_CLASS} whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none ${MENU_ROW_TRANSITION_CLASS} ${MENU_ROW_HIGHLIGHT_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,
className
)}
{...props}
Expand Down
Loading