fix: keep directory picker menu within viewport

The shared directory picker is used from right-aligned controls such as the MCP target project selector. Its portal menu previously used the trigger's raw left coordinate, so a trigger near the window edge could render the 400px menu partly off-screen.

Clamp the fixed-position menu against the viewport while preserving the existing width where space allows, and cover the right-edge case with a focused component test.

Constraint: DirectoryPicker is shared by MCP settings, task prompts, adapter settings, and launch controls.
Rejected: Special-case the MCP form layout | the overflow comes from the shared portal positioning and would remain in other right-aligned uses.
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bun run test -- src/components/shared/DirectoryPicker.test.tsx
Tested: bun run check:desktop
Not-tested: Manual browser smoke; local agent-browser automation hung during startup/command evaluation.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 20:10:24 +08:00
parent bda7d86a05
commit 21954e5f5c
2 changed files with 54 additions and 5 deletions

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
vi.mock('../../api/sessions', () => ({
@ -19,6 +19,17 @@ import { sessionsApi } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
describe('DirectoryPicker', () => {
let originalInnerWidth: number
beforeEach(() => {
originalInnerWidth = window.innerWidth
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
vi.restoreAllMocks()
})
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
render(
<DirectoryPicker
@ -109,6 +120,36 @@ describe('DirectoryPicker', () => {
expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument()
})
it('keeps the recent-project menu inside the viewport when the trigger is near the right edge', async () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
render(
<DirectoryPicker
value="/workspace/project"
onChange={vi.fn()}
/>,
)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
x: 920,
y: 24,
top: 24,
left: 920,
right: 1010,
bottom: 60,
width: 90,
height: 36,
toJSON: () => ({}),
} as DOMRect)
fireEvent.click(trigger)
const menu = await screen.findByTestId('directory-picker-menu')
expect(menu).toHaveStyle({ left: '612px', width: '400px' })
})
it('renders browse entries without nesting interactive buttons', async () => {
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
vi.mocked(filesystemApi.browse).mockResolvedValue({

View File

@ -20,6 +20,9 @@ let cachedProjects: RecentProject[] | null = null
let cacheTimestamp = 0
const CACHE_TTL = 30_000 // 30s
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
const DROPDOWN_WIDTH = 400
const DROPDOWN_VIEWPORT_MARGIN = 12
const DROPDOWN_HEIGHT = 380 // approximate max height
function isTauriRuntime() {
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
@ -41,7 +44,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const [browsePath, setBrowsePath] = useState('')
const [browseParent, setBrowseParent] = useState('')
const [loading, setLoading] = useState(false)
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number; direction: 'up' | 'down' } | null>(null)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
@ -51,13 +54,16 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const updateDropdownPos = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const DROPDOWN_HEIGHT = 380 // approximate max height
const spaceAbove = rect.top
const spaceBelow = window.innerHeight - rect.bottom
const direction = spaceBelow >= DROPDOWN_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
const width = Math.min(DROPDOWN_WIDTH, Math.max(0, window.innerWidth - DROPDOWN_VIEWPORT_MARGIN * 2))
const maxLeft = Math.max(DROPDOWN_VIEWPORT_MARGIN, window.innerWidth - width - DROPDOWN_VIEWPORT_MARGIN)
const left = Math.min(Math.max(rect.left, DROPDOWN_VIEWPORT_MARGIN), maxLeft)
setDropdownPos({
top: direction === 'down' ? rect.bottom + 4 : rect.top - 4,
left: rect.left,
left,
width,
direction,
})
}, [])
@ -159,10 +165,11 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
const dropdownClassName = 'w-[400px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const dropdownClassName = 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const dropdownStyle = {
position: 'fixed' as const,
left: dropdownPos?.left,
width: dropdownPos?.width,
...(dropdownPos?.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - (dropdownPos?.top ?? 0) }),
@ -340,6 +347,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
) : createPortal(
<div
ref={dropdownRef}
data-testid="directory-picker-menu"
className={dropdownClassName}
style={dropdownStyle}
>