From 21954e5f5ce5c46d868a5270901882149c128c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 25 May 2026 20:10:24 +0800 Subject: [PATCH] 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. --- .../shared/DirectoryPicker.test.tsx | 43 ++++++++++++++++++- .../src/components/shared/DirectoryPicker.tsx | 16 +++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/desktop/src/components/shared/DirectoryPicker.test.tsx b/desktop/src/components/shared/DirectoryPicker.test.tsx index 75b2d2a2..e762fdcb 100644 --- a/desktop/src/components/shared/DirectoryPicker.test.tsx +++ b/desktop/src/components/shared/DirectoryPicker.test.tsx @@ -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( { 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( + , + ) + + 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({ diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 3d063955..446fc671 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -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(null) const triggerRef = useRef(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(