diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index a94c538d..0e060e0e 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -189,7 +189,14 @@ and the send path reading the same grammar. also owns the advisory dirty state for the selected directory, clearing it as soon as the target changes so a warning never names a previous branch. - `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker - state and registers its application shortcuts locally. The selectors only + state and registers its application shortcuts locally. The desktop project + picker is a searchable popup: it ranks the current projects with + `rankByQuery` over display label and path, keeps the query and the active + result as transient local state that resets on every close, and commits + through the existing project-change flow only on explicit activation. + Filtering changes the result area below the anchored input without moving + the search field. The + worktree Select and the mobile bottom sheets are unchanged. The selectors only consume their shared prefix while the draft target UI is mounted. ## Input recall ownership diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 5c6e2578..ab326fb6 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -1,10 +1,11 @@ /** * Where a new session will run: the project and the directory within it. * - * Desktop uses inline selects; mobile uses bottom sheets, because a native - * select over a keyboard-resized viewport is unusable. Both render the same - * options from the same hook, and both offer creating a worktree inline so the - * user does not have to leave the draft to make one. + * Desktop uses a searchable project popup and an inline branch/worktree + * select; mobile uses bottom sheets, because a native select over a + * keyboard-resized viewport is unusable. Both render the same options from + * the same hook, and both offer creating a worktree inline so the user does + * not have to leave the draft to make one. */ import React from 'react'; @@ -12,8 +13,19 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; +import { Popover } from '@base-ui/react/popover'; +import { cn } from '@/lib/utils'; +import { dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles'; +import { + Command, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { handleDropdownNavigationKey, shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; +import { isIMECompositionEvent } from '@/lib/ime'; import { Select, SelectContent, @@ -28,6 +40,7 @@ import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; +import { shortcutRegistry } from '@/lib/shortcuts'; import { useKeybind } from '@/hooks/useKeybind'; import type { Theme } from '@/types/theme'; import { normalizePath } from '../attachments/filePaths'; @@ -145,8 +158,34 @@ export function DraftTargetSelectors(props: DraftTargetProps) { theme, } = props; const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null); + const [projectQuery, setProjectQuery] = React.useState(''); + const [projectActiveId, setProjectActiveId] = React.useState(null); + const [projectFocusReturn, setProjectFocusReturn] = React.useState(false); const projectTriggerRef = React.useRef(null); const worktreeTriggerRef = React.useRef(null); + const projectSearchRef = React.useRef(null); + // Preserve Select's dialog portal and main-area containment. + const [projectPortalContainer, setProjectPortalContainer] = React.useState(null); + const [projectCollisionBoundary, setProjectCollisionBoundary] = React.useState(null); + const syncProjectPopupContainers = React.useCallback((target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + const dialog = element?.closest('[data-slot="dialog-content"], [role="dialog"]'); + setProjectPortalContainer(dialog instanceof HTMLElement ? dialog : null); + setProjectCollisionBoundary(element?.closest('main') ?? null); + }, []); + // The popover owns no shortcut suspension (unlike Select/DropdownMenu + // wrappers), so suspend global shortcuts while the project popup is + // open and restore them on close/unmount. + const projectSuspendRef = React.useRef<(() => void) | null>(null); + React.useEffect(() => { + if (openPicker !== 'project') return; + projectSuspendRef.current?.(); + projectSuspendRef.current = shortcutRegistry.suspend(); + return () => { + projectSuspendRef.current?.(); + projectSuspendRef.current = null; + }; + }, [openPicker]); const handlePickerKeyDown = (event: React.KeyboardEvent) => { if (openPicker === null || !shouldDismissDropdown(event)) return; event.preventDefault(); @@ -156,6 +195,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) { useKeybind('open_draft_project_picker', () => { projectTriggerRef.current?.focus(); + setProjectFocusReturn(false); + setProjectQuery(''); + setProjectActiveId( + projects.some((project) => project.id === selectedProject.id) ? selectedProject.id : null, + ); setOpenPicker('project'); }); useKeybind('open_draft_worktree_picker', () => { @@ -164,11 +208,43 @@ export function DraftTargetSelectors(props: DraftTargetProps) { setOpenPicker('worktree'); }); + const filteredProjects = React.useMemo( + () => openPicker === 'project' + ? rankByQuery(projects, projectQuery, (project) => [getProjectDisplayLabel(project), project.path]) + : projects, + [openPicker, projects, projectQuery], + ); + + // Transient search must never survive to the next opening, including + // picker switches and draft unmounts. + React.useEffect(() => { + if (openPicker !== 'project') { + setProjectQuery(''); + setProjectActiveId(null); + } + }, [openPicker]); + // After a query or project-list change, retain the active ID only + // while it stays visible; otherwise fall to the first result (or none + // when the list is empty) so Enter cannot commit a hidden row. + React.useEffect(() => { + if (openPicker !== 'project') return; + setProjectActiveId((current) => { + if (current && filteredProjects.some((project) => project.id === current)) return current; + return filteredProjects[0]?.id ?? null; + }); + }, [filteredProjects, openPicker]); + const handleProjectChange = (projectId: string) => { onProjectChange(projectId); + setProjectFocusReturn(true); setOpenPicker(null); }; + const handleProjectSelect = (projectId: string) => { + if (!filteredProjects.some((project) => project.id === projectId)) return; + handleProjectChange(projectId); + }; + const handleDirectoryChange = (directory: string) => { onDirectoryChange(directory); setOpenPicker(null); @@ -176,33 +252,152 @@ export function DraftTargetSelectors(props: DraftTargetProps) { return (
- + + + + + {/* side="bottom" anchors the popup's top edge at the + trigger: the search field stays fixed at the + selector's level while filtering, and only the + results area below changes height. Disabling side + flips keeps the input stationary during filtering. + Horizontal shifting keeps the popup inside main. The + --available-height cap comes free from the shared + popup class, so the list scrolls within the space + below the trigger. */} + + + {/* Filtering and ordering are owned by rankByQuery above; + cmdk's own filter would re-filter and reorder the + already-ranked rows. */} + + { + // Command owns active-item navigation, so only + // translate the repository's Ctrl+N/P + // convention into arrows at the input. + // IME-composing keys must never move the + // active row or dismiss the popup. + if (isIMECompositionEvent(event)) { + event.stopPropagation(); + return; + } + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }} + /> + + {filteredProjects.length === 0 ? ( +
+ {t('chat.chatInput.draftPicker.noProjectsFound')} +
+ ) : ( + filteredProjects.map((project) => ( + + + + + {project.id === selectedProject.id ? ( + + ) : null} + + )) + )} +
+
+
+
+
+ {showBranchSelector ? (