fix(desktop): keep the settings permission menu inside the window

The default-permission menu in Settings > General grew rightwards from the
trigger's left edge. The trigger sits at the end of the row, so the 340px
menu ran past the content column and was clipped. Add a menuAlign option
and right-align the menu there; composer callers keep the left alignment.

Fixes #1474
This commit is contained in:
程序员阿江(Relakkes)
2026-10-09 13:08:19 +08:00
parent 4fa5684629
commit 723d6af54e
4 changed files with 67 additions and 2 deletions
@@ -479,6 +479,26 @@ describe('PermissionModeSelector', () => {
expect(onChange).toHaveBeenCalledWith('acceptEdits') expect(onChange).toHaveBeenCalledWith('acceptEdits')
}) })
it('opens the desktop menu from the trigger start edge by default', () => {
render(<PermissionModeSelector value="default" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
const menu = screen.getByRole('menu')
expect(menu).toHaveClass('left-0')
expect(menu).not.toHaveClass('right-0')
})
it('anchors the desktop menu to the trigger end edge when asked to align right', () => {
render(<PermissionModeSelector value="default" onChange={vi.fn()} menuAlign="right" />)
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
const menu = screen.getByRole('menu')
expect(menu).toHaveClass('right-0')
expect(menu).not.toHaveClass('left-0')
})
it('shows Auto beside the existing permission modes', () => { it('shows Auto beside the existing permission modes', () => {
render(<PermissionModeSelector value="default" onChange={vi.fn()} />) render(<PermissionModeSelector value="default" onChange={vi.fn()} />)
@@ -73,6 +73,11 @@ type Props = {
*/ */
trigger?: 'chip' | 'elevatedOnly' trigger?: 'chip' | 'elevatedOnly'
menuPlacement?: 'top' | 'bottom' menuPlacement?: 'top' | 'bottom'
/**
* Which trigger edge the desktop menu lines up with. `right` keeps it inside
* the container when the trigger sits at the end of a row, as in Settings.
*/
menuAlign?: 'left' | 'right'
/** Controlled mode: override current value */ /** Controlled mode: override current value */
value?: PermissionMode value?: PermissionMode
/** Controlled mode: called on change instead of updating global store */ /** Controlled mode: called on change instead of updating global store */
@@ -80,7 +85,7 @@ type Props = {
} }
export const PermissionModeSelector = forwardRef<PermissionModeSelectorHandle, Props>(function PermissionModeSelector( export const PermissionModeSelector = forwardRef<PermissionModeSelectorHandle, Props>(function PermissionModeSelector(
{ sessionId, workDir: workDirProp, compact = false, trigger = 'chip', menuPlacement = 'top', value, onChange }: Props = {}, { sessionId, workDir: workDirProp, compact = false, trigger = 'chip', menuPlacement = 'top', menuAlign = 'left', value, onChange }: Props = {},
handleRef, handleRef,
) { ) {
const t = useTranslation() const t = useTranslation()
@@ -175,6 +180,7 @@ export const PermissionModeSelector = forwardRef<PermissionModeSelectorHandle, P
const menuPlacementClass = menuPlacement === 'bottom' const menuPlacementClass = menuPlacement === 'bottom'
? 'top-full mt-2' ? 'top-full mt-2'
: 'bottom-full mb-2' : 'bottom-full mb-2'
const menuAlignClass = menuAlign === 'right' ? 'right-0' : 'left-0'
// Generated, not hard-coded: the previous literal id was rendered by both the // Generated, not hard-coded: the previous literal id was rendered by both the
// sheet branch and the desktop branch, so `aria-controls` pointed at whichever // sheet branch and the desktop branch, so `aria-controls` pointed at whichever
// duplicate the browser resolved first. // duplicate the browser resolved first.
@@ -350,7 +356,7 @@ export const PermissionModeSelector = forwardRef<PermissionModeSelectorHandle, P
</div> </div>
</MobileBottomSheet> </MobileBottomSheet>
) : ( ) : (
<div id={menuId} ref={menuRef} role="menu" className={`absolute left-0 ${menuPlacementClass} z-[var(--z-dropdown)] w-[340px] ${COMPOSER_POPOVER}`}> <div id={menuId} ref={menuRef} role="menu" className={`absolute ${menuAlignClass} ${menuPlacementClass} z-[var(--z-dropdown)] w-[340px] ${COMPOSER_POPOVER}`}>
<div className={COMPOSER_MENU_SECTION}> <div className={COMPOSER_MENU_SECTION}>
{t('permMode.executionPermissions')} {t('permMode.executionPermissions')}
</div> </div>
@@ -952,6 +952,7 @@ export function GeneralSettings() {
onChange={(mode) => void setPermissionMode(mode)} onChange={(mode) => void setPermissionMode(mode)}
workDir={t('settings.general.defaultPermissionScope')} workDir={t('settings.general.defaultPermissionScope')}
menuPlacement="bottom" menuPlacement="bottom"
menuAlign="right"
/> />
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
@@ -0,0 +1,38 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useOpenTargetStore } from '@/stores/openTargetStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { GeneralSettings } from './GeneralSettings'
describe('General settings default permission menu', () => {
beforeEach(() => {
useSettingsStore.setState({
locale: 'zh',
permissionMode: 'default',
fetchOutputStyles: async () => {},
fetchAppMode: async () => {},
})
useOpenTargetStore.setState({ ensureTargets: async () => {} })
})
afterEach(() => {
cleanup()
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
useOpenTargetStore.setState(useOpenTargetStore.getInitialState(), true)
})
// The trigger sits at the end of a settings row, against the right edge of
// the scrolling content column. A menu growing rightwards from the trigger's
// left edge ran past the window and was clipped (#1474).
it('opens the menu leftwards from the trigger at the end of the row', () => {
render(<GeneralSettings />)
fireEvent.click(screen.getByRole('button', { name: '询问权限' }))
const menu = screen.getByRole('menu')
expect(within(menu).getByRole('menuitem', { name: /询问权限/ })).toBeInTheDocument()
expect(menu).toHaveClass('right-0')
expect(menu).not.toHaveClass('left-0')
})
})