mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(desktop): help menu
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
],
|
||||
|
||||
+4
-1
@@ -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 })}
|
||||
>
|
||||
|
||||
+3
-2
@@ -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 })}
|
||||
|
||||
+3
-2
@@ -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 />}
|
||||
|
||||
+6
-9
@@ -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>
|
||||
@@ -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>
|
||||
@@ -265,7 +262,7 @@ export function CollapsedChatFlyoutItem({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className={cn((isCurrentRoute || isMenuOpen) && 'bg-[var(--surface-active)]')}
|
||||
active={isCurrentRoute || isMenuOpen}
|
||||
action={
|
||||
showActions ? (
|
||||
<DropdownMenuItemAction
|
||||
@@ -342,7 +339,7 @@ export function CollapsedWorkflowFlyoutItem({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className={cn((isCurrentRoute || actionsOpen) && 'bg-[var(--surface-active)]')}
|
||||
active={isCurrentRoute || actionsOpen}
|
||||
action={
|
||||
hasActions ? (
|
||||
<DropdownMenuSub
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)}
|
||||
@@ -214,12 +244,22 @@ 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
|
||||
@@ -227,8 +267,9 @@ const DropdownMenuItem = React.forwardRef<
|
||||
*/
|
||||
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'>
|
||||
@@ -236,7 +277,8 @@ const DropdownMenuItem = React.forwardRef<
|
||||
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
|
||||
)}
|
||||
@@ -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}
|
||||
>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user