diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 938e2f51b..0a5e57a12 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -173,7 +173,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { .first(); await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 }); - const input = page.getByRole('textbox', { name: '/path/to/project' }); + const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first(); + const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first(); const worktreePicker = page.getByTestId('worktree-attach-picker'); const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }); const closeBottomSheet = async () => { @@ -218,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { await closeWorktreeSheetIfOpen(); - if (!(await input.isVisible())) { + const pickerInputVisible = async () => + (await directorySearchInput.isVisible().catch(() => false)) || + (await legacyInput.isVisible().catch(() => false)); + + if (!(await pickerInputVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); - if (!(await input.isVisible())) { + if (!(await pickerInputVisible())) { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); } - await expect(input).toBeVisible(); + await expect + .poll(async () => pickerInputVisible(), { timeout: 10000 }) + .toBe(true); } const trimmedDirectory = directory.replace(/\/+$/, ''); - await input.fill(trimmedDirectory); - await input.press('Enter'); + const activeInput = + (await directorySearchInput.isVisible().catch(() => false)) + ? directorySearchInput + : legacyInput; - // Desktop web supports selecting via Enter; mobile may require clicking the explicit option. - const useOption = page.getByTestId('working-directory-custom-option').first(); - if (await useOption.isVisible().catch(() => false)) { - await expect(useOption).toContainText(`Use "${trimmedDirectory}"`); - await useOption.click({ force: true }); + await activeInput.fill(trimmedDirectory); + + if (activeInput === directorySearchInput) { + // Combobox custom rows can be either plain path labels or prefixed labels. + const plainOption = page + .getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i')) + .first(); + const prefixedUseOption = page + .getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i')) + .first(); + + if (await plainOption.isVisible().catch(() => false)) { + await plainOption.click({ force: true }); + } else if (await prefixedUseOption.isVisible().catch(() => false)) { + await prefixedUseOption.click({ force: true }); + } else { + // Fallback: accept highlighted option (directory suggestion). + await activeInput.press('Enter'); + } + } else { + // Legacy path picker fallback. + await activeInput.press('Enter'); } - // Wait for the sheet to close after clicking "Use" - await expect(input).not.toBeVisible({ timeout: 10000 }); + // Wait for picker to close. + await expect(activeInput).not.toBeVisible({ timeout: 10000 }); const directoryCandidates = new Set([trimmedDirectory]); if (trimmedDirectory.startsWith('/var/')) { diff --git a/packages/app/e2e/working-directory-combobox-positioning.spec.ts b/packages/app/e2e/working-directory-combobox-positioning.spec.ts new file mode 100644 index 000000000..cff83ce83 --- /dev/null +++ b/packages/app/e2e/working-directory-combobox-positioning.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from "./fixtures"; +import { gotoHome } from "./helpers/app"; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +test("working directory combobox stays visually stable while typing search", async ({ page }) => { + await gotoHome(page); + + const workingDirectorySelect = page + .locator('[data-testid="working-directory-select"]:visible') + .first(); + await expect(workingDirectorySelect).toBeVisible(); + await workingDirectorySelect.click({ force: true }); + + const searchInput = page.getByRole("textbox", { name: /search directories/i }).first(); + await expect(searchInput).toBeVisible(); + + await page.evaluate(() => { + const trigger = document.querySelector('[data-testid="working-directory-select"]'); + const searchInput = document.querySelector('input[placeholder="Search directories..."]'); + const container = document.querySelector('[data-testid="combobox-desktop-container"]'); + if (!(trigger instanceof HTMLElement)) { + throw new Error("Missing working-directory-select trigger."); + } + if (!(searchInput instanceof HTMLInputElement)) { + throw new Error("Missing working directory search input."); + } + if (!(container instanceof HTMLElement)) { + throw new Error("Missing combobox desktop container."); + } + + const state = { + samples: 0, + underTriggerSamples: 0, + emptyWhileSearchingSamples: 0, + minSearchDelta: Number.POSITIVE_INFINITY, + maxSearchDelta: Number.NEGATIVE_INFINITY, + minContainerDelta: Number.POSITIVE_INFINITY, + maxContainerDelta: Number.NEGATIVE_INFINITY, + logs: [] as Array<{ + reason: string; + query: string; + searchDelta: number; + containerDelta: number; + hasEmpty: boolean; + containerTop: number; + containerBottom: number; + triggerTop: number; + searchBottom: number; + }>, + }; + + const sample = (reason: string) => { + if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) { + return; + } + const query = searchInput.value.trim(); + if (!query) { + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const searchRect = searchInput.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]')); + const searchDelta = searchRect.bottom - triggerRect.top; + const containerDelta = containerRect.bottom - triggerRect.top; + + state.samples += 1; + state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta); + state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta); + state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta); + state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta); + if (searchDelta > 2 || containerDelta > 2) { + state.underTriggerSamples += 1; + } + if (hasEmpty) { + state.emptyWhileSearchingSamples += 1; + } + if (state.logs.length < 80) { + state.logs.push({ + reason, + query, + searchDelta, + containerDelta, + hasEmpty, + containerTop: containerRect.top, + containerBottom: containerRect.bottom, + triggerTop: triggerRect.top, + searchBottom: searchRect.bottom, + }); + } + }; + + const mutationObserver = new MutationObserver(() => sample("mutation")); + mutationObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + const resizeObserver = new ResizeObserver(() => sample("resize")); + resizeObserver.observe(container); + resizeObserver.observe(searchInput); + + let rafId = 0; + const loop = () => { + sample("raf"); + rafId = requestAnimationFrame(loop); + }; + rafId = requestAnimationFrame(loop); + + (window as any).__paseoComboboxObserver = { + stop: () => { + cancelAnimationFrame(rafId); + mutationObserver.disconnect(); + resizeObserver.disconnect(); + sample("stop"); + return { + ...state, + minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0, + maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0, + minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0, + maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0, + }; + }, + }; + }); + + const queries = [ + "/tmp/paseo-stability-a", + "/tmp/paseo-stability-ab", + "/tmp/paseo-stability-abc", + "/tmp/paseo-stability-longer-branch", + "/tmp/paseo-stability-z", + ]; + + for (const query of queries) { + await searchInput.fill(""); + await searchInput.type(query, { delay: 20 }); + const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first(); + await expect(customOption).toBeVisible(); + await page.waitForTimeout(100); + } + + const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop()); + const debug = JSON.stringify(stats.logs.slice(-10)); + + expect(stats.samples, debug).toBeGreaterThan(20); + expect(stats.underTriggerSamples, debug).toBe(0); + expect(stats.emptyWhileSearchingSamples, debug).toBe(0); + expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3); + expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3); + expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2); +}); diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index c1c44d147..efb949666 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -885,6 +885,9 @@ export function WorkingDirectoryDropdown({ onSelect={onSelectPath} searchPlaceholder="Search directories..." emptyText={emptyText} + allowCustomValue + customValuePrefix="" + customValueKind="directory" optionsPosition="above-search" title="Working directory" open={isOpen} diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts new file mode 100644 index 000000000..3c6a9addf --- /dev/null +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + buildVisibleComboboxOptions, + getComboboxFallbackIndex, + orderVisibleComboboxOptions, +} from "./combobox-options"; + +describe("buildVisibleComboboxOptions", () => { + const options = [ + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const }, + { id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const }, + ]; + + it("keeps a custom row visible while searching with no matches", () => { + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "/tmp/new-project", + searchable: true, + allowCustomValue: true, + customValuePrefix: "", + customValueKind: "directory", + }); + + expect(visible).toHaveLength(1); + expect(visible[0]).toEqual({ + id: "/tmp/new-project", + label: "/tmp/new-project", + description: undefined, + kind: "directory", + }); + }); + + it("does not duplicate a row when search exactly matches an existing option", () => { + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "/Users/me/project-a", + searchable: true, + allowCustomValue: true, + customValuePrefix: "", + customValueKind: "directory", + }); + + expect(visible).toEqual([ + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" }, + ]); + }); +}); + +describe("combobox above-search ordering", () => { + const visible = [ + { id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const }, + { id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const }, + { id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const }, + ]; + + it("renders first logical option closest to the search box in above-search mode", () => { + const ordered = orderVisibleComboboxOptions(visible, "above-search"); + expect(ordered.map((option) => option.id)).toEqual([ + "/Users/me/project-b", + "/Users/me/project-a", + "/tmp/new-project", + ]); + expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2); + }); + + it("keeps normal top-down order in below-search mode", () => { + const ordered = orderVisibleComboboxOptions(visible, "below-search"); + expect(ordered.map((option) => option.id)).toEqual([ + "/tmp/new-project", + "/Users/me/project-a", + "/Users/me/project-b", + ]); + expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0); + }); +}); diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts new file mode 100644 index 000000000..b58f0b421 --- /dev/null +++ b/packages/app/src/components/ui/combobox-options.ts @@ -0,0 +1,95 @@ +export interface ComboboxOptionModel { + id: string; + label: string; + description?: string; + kind?: "directory"; +} + +export interface BuildVisibleComboboxOptionsInput { + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; + customValuePrefix: string; + customValueDescription?: string; + customValueKind?: "directory"; +} + +export function shouldShowCustomComboboxOption(input: { + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; +}): boolean { + const sanitizedSearchValue = input.searchQuery.trim(); + if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { + return false; + } + + return !input.options.some( + (opt) => + opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || + opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() + ); +} + +export function buildVisibleComboboxOptions( + input: BuildVisibleComboboxOptionsInput +): ComboboxOptionModel[] { + const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; + const filteredOptions = normalizedSearch + ? input.options.filter( + (opt) => + opt.label.toLowerCase().includes(normalizedSearch) || + opt.id.toLowerCase().includes(normalizedSearch) || + opt.description?.toLowerCase().includes(normalizedSearch) + ) + : input.options; + + const sanitizedSearchValue = input.searchQuery.trim(); + const showCustomOption = shouldShowCustomComboboxOption({ + options: input.options, + searchQuery: input.searchQuery, + searchable: input.searchable, + allowCustomValue: input.allowCustomValue, + }); + + const visibleOptions: ComboboxOptionModel[] = []; + + if (showCustomOption) { + const trimmedPrefix = input.customValuePrefix.trim(); + const customLabel = + trimmedPrefix.length > 0 + ? `${trimmedPrefix} "${sanitizedSearchValue}"` + : sanitizedSearchValue; + visibleOptions.push({ + id: sanitizedSearchValue, + label: customLabel, + description: input.customValueDescription, + kind: input.customValueKind, + }); + } + + visibleOptions.push(...filteredOptions); + return visibleOptions; +} + +export function orderVisibleComboboxOptions( + visibleOptions: ComboboxOptionModel[], + optionsPosition: "below-search" | "above-search" +): ComboboxOptionModel[] { + if (optionsPosition !== "above-search") { + return visibleOptions; + } + return [...visibleOptions].reverse(); +} + +export function getComboboxFallbackIndex( + itemCount: number, + optionsPosition: "below-search" | "above-search" +): number { + if (itemCount <= 0) { + return -1; + } + return optionsPosition === "above-search" ? itemCount - 1 : 0; +} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 87b53e0cc..1f48e28bb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; import { View, @@ -9,6 +9,7 @@ import { ScrollView, Platform, StatusBar, + useWindowDimensions, } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { @@ -22,15 +23,17 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, Folder, Search } from "lucide-react-native"; import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; import { getNextActiveIndex } from "./combobox-keyboard"; +import { + buildVisibleComboboxOptions, + getComboboxFallbackIndex, + orderVisibleComboboxOptions, + shouldShowCustomComboboxOption, +} from "./combobox-options"; +import type { ComboboxOptionModel } from "./combobox-options"; const IS_WEB = Platform.OS === "web"; -export interface ComboboxOption { - id: string; - label: string; - description?: string; - kind?: "directory"; -} +export type ComboboxOption = ComboboxOptionModel; export interface ComboboxProps { options: ComboboxOption[]; @@ -181,7 +184,7 @@ export function ComboboxItem({ } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { - return {children}; + return {children}; } export function Combobox({ @@ -210,13 +213,18 @@ export function Combobox({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; + const isDesktopAboveSearch = + !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; + const { height: windowHeight } = useWindowDimensions(); const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [referenceWidth, setReferenceWidth] = useState(null); + const [referenceTop, setReferenceTop] = useState(null); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); + const desktopOptionsScrollRef = useRef(null); const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); @@ -262,7 +270,7 @@ export function Combobox({ () => [ floatingOffset(Platform.OS === "web" ? 0 : 4), ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), - shift({ padding: collisionPadding }), + ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, apply({ availableWidth, availableHeight, rects }) { @@ -280,7 +288,7 @@ export function Combobox({ }, }), ], - [collisionPadding] + [collisionPadding, isDesktopAboveSearch] ); const { refs, floatingStyles, update } = useFloating({ @@ -305,30 +313,49 @@ export function Combobox({ useEffect(() => { if (!isOpen || isMobile) { setReferenceAtOrigin(false); + setReferenceTop(null); return; } const referenceEl = anchorRef.current; if (!referenceEl) { setReferenceAtOrigin(false); + setReferenceTop(null); return; } - referenceEl.measureInWindow((x, y) => { - setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); - }); - }, [anchorRef, isMobile, isOpen]); + const measure = () => { + referenceEl.measureInWindow((x, y) => { + setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); + setReferenceTop((prev) => (prev === y ? prev : y)); + }); + }; + + measure(); + const raf = requestAnimationFrame(measure); + return () => cancelAnimationFrame(raf); + }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); const floatingTop = toNumericStyleValue(floatingStyles.top); const floatingLeft = toNumericStyleValue(floatingStyles.left); + const desktopAboveSearchBottom = + isDesktopAboveSearch && referenceTop !== null + ? Math.max(windowHeight - referenceTop, collisionPadding) + : null; const hasResolvedDesktopPosition = referenceWidth !== null && - floatingTop !== null && floatingLeft !== null && - (floatingTop !== 0 || floatingLeft !== 0 || referenceAtOrigin); + (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && + ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash; + const desktopPositionStyle = isDesktopAboveSearch + ? { + left: floatingLeft ?? 0, + bottom: desktopAboveSearchBottom ?? 0, + } + : floatingStyles; useEffect(() => { if (!isMobile) return; @@ -361,76 +388,75 @@ export function Combobox({ ); const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; - const filteredOptions = useMemo(() => { - if (!normalizedSearch) { - return options; - } - return options.filter( - (opt) => - opt.label.toLowerCase().includes(normalizedSearch) || - opt.id.toLowerCase().includes(normalizedSearch) || - opt.description?.toLowerCase().includes(normalizedSearch) - ); - }, [options, normalizedSearch]); - const sanitizedSearchValue = searchQuery.trim(); - const showCustomOption = - searchable && - allowCustomValue && - sanitizedSearchValue.length > 0 && - !options.some( - (opt) => - opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || - opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() - ); + const showCustomOption = useMemo( + () => + shouldShowCustomComboboxOption({ + options, + searchQuery, + searchable, + allowCustomValue, + }), + [allowCustomValue, options, searchQuery, searchable] + ); - const visibleOptions = useMemo(() => { - const next: Array<{ - id: string; - label: string; - description?: string; - kind?: "directory"; - }> = []; + const visibleOptions = useMemo( + () => + buildVisibleComboboxOptions({ + options, + searchQuery, + searchable, + allowCustomValue, + customValuePrefix, + customValueDescription, + customValueKind, + }), + [ + allowCustomValue, + customValueDescription, + customValueKind, + customValuePrefix, + options, + searchQuery, + searchable, + ] + ); - if (showCustomOption) { - const trimmedPrefix = customValuePrefix.trim(); - const customLabel = - trimmedPrefix.length > 0 - ? `${trimmedPrefix} "${sanitizedSearchValue}"` - : sanitizedSearchValue; - next.push({ - id: sanitizedSearchValue, - label: customLabel, - description: customValueDescription, - kind: customValueKind, - }); + const orderedVisibleOptions = useMemo( + () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), + [effectiveOptionsPosition, visibleOptions] + ); + + const pinDesktopOptionsToBottom = useCallback(() => { + if (isMobile || effectiveOptionsPosition !== "above-search") { + return; } + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + requestAnimationFrame(() => { + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + }); + }, [effectiveOptionsPosition, isMobile]); - for (const opt of filteredOptions) { - next.push({ - id: opt.id, - label: opt.label, - description: opt.description, - kind: opt.kind, - }); + const handleDesktopOptionsContentSizeChange = useCallback(() => { + if (!isOpen) { + return; } + pinDesktopOptionsToBottom(); + }, [isOpen, pinDesktopOptionsToBottom]); - return next; - }, [ - customValueDescription, - customValueKind, - customValuePrefix, - filteredOptions, - sanitizedSearchValue, - showCustomOption, - ]); - - const orderedVisibleOptions = useMemo(() => { - if (effectiveOptionsPosition !== "above-search") { - return visibleOptions; + useEffect(() => { + if (!isOpen) { + return; } - return [...visibleOptions].reverse(); - }, [effectiveOptionsPosition, visibleOptions]); + pinDesktopOptionsToBottom(); + }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); + + useLayoutEffect(() => { + if (!isOpen || isMobile) { + return; + } + void update(); + }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); useEffect(() => { if (!isOpen) return; @@ -441,8 +467,10 @@ export function Combobox({ return; } - const fallbackIndex = - effectiveOptionsPosition === "above-search" ? orderedVisibleOptions.length - 1 : 0; + const fallbackIndex = getComboboxFallbackIndex( + orderedVisibleOptions.length, + effectiveOptionsPosition + ); if (normalizedSearch) { setActiveIndex(fallbackIndex); @@ -607,6 +635,7 @@ export function Combobox({ ) : ( <> + {effectiveOptionsPosition === "above-search" ? ( + + {optionsList} + + ) : null} {searchable ? searchInput : null} - - {optionsList} - + {effectiveOptionsPosition === "below-search" ? ( + + {optionsList} + + ) : null} )} @@ -773,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", }, desktopScroll: { - maxHeight: 400, + flexShrink: 1, + minHeight: 0, }, desktopScrollContent: { paddingVertical: theme.spacing[1], }, + desktopScrollContentAboveSearch: { + flexGrow: 1, + justifyContent: "flex-end", + }, })); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index bc3aa6bac..ca98a5700 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1191,6 +1191,9 @@ export function DraftAgentScreen({ onSearchQueryChange={setWorkingDirSearchQuery} searchPlaceholder="Search directories..." emptyText={workingDirEmptyText} + allowCustomValue + customValuePrefix="" + customValueKind="directory" optionsPosition="above-search" title="Working directory" open={isWorkingDirOpen}