feat: add search to the new session project picker (#3408)

* feat: add search to the new session project picker

* fix: punctuate the project picker empty state
This commit is contained in:
Maxim Topciu
2026-09-07 20:25:19 +03:00
committed by GitHub
parent 0e836e7b75
commit fc61b10109
14 changed files with 241 additions and 27 deletions
@@ -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
@@ -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<string | null>(null);
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const projectSearchRef = React.useRef<HTMLInputElement>(null);
// Preserve Select's dialog portal and main-area containment.
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
const [projectCollisionBoundary, setProjectCollisionBoundary] = React.useState<Element | null>(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<HTMLElement>) => {
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 (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
{/* Plain popover (not a menu): the popup owns a combobox +
listbox, so menu/menuitem semantics would be wrong here. */}
<Popover.Root
open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
onOpenChange={(open, eventDetails) => {
if (open) {
setProjectFocusReturn(false);
// Seed the active row from the committed project so
// Enter without typing repeats the current choice.
// The clamp below keeps it when still visible and
// falls to the first result otherwise.
setProjectQuery('');
setProjectActiveId(
projects.some((project) => project.id === selectedProject.id)
? selectedProject.id
: null,
);
setOpenPicker('project');
return;
}
setProjectQuery('');
setProjectActiveId(null);
const reason = eventDetails?.reason;
setProjectFocusReturn(reason === 'escape-key');
setOpenPicker(null);
}}
onOpenChangeComplete={(open) => {
// Return focus after Base UI finishes closing so the
// trigger itself, not document body, keeps keyboard flow.
if (!open && projectFocusReturn) {
projectTriggerRef.current?.focus();
setProjectFocusReturn(false);
}
// Focus the search once the popup mounts; the opening
// shortcut focuses the trigger first.
if (open && openPicker === 'project') projectSearchRef.current?.focus();
}}
>
<SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
<Popover.Trigger
render={
<Button
ref={projectTriggerRef}
variant="ghost"
size="sm"
aria-haspopup="dialog"
className="h-7 min-w-0 w-fit max-w-[42vw] justify-start gap-1 px-1.5 normal-case sm:max-w-[18rem]"
onPointerDownCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
onFocusCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
/>
}
>
<SelectValue>
<span className="flex min-w-0 items-center gap-1.5">
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
</Select>
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
</span>
</Popover.Trigger>
<Popover.Portal container={projectPortalContainer ?? undefined}>
{/* 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. */}
<Popover.Positioner
side="bottom"
align="start"
sideOffset={4}
collisionAvoidance={{ side: 'none' }}
collisionBoundary={projectCollisionBoundary ?? undefined}
className="app-region-no-drag z-50"
>
<Popover.Popup
role="dialog"
aria-label={t('chat.chatInput.draftPicker.projectTitle')}
className={cn(dropdownMenuPopupClass, 'flex w-72 max-w-[calc(100vw-2rem)] flex-col p-0')}
initialFocus={false}
finalFocus={false}
>
{/* Filtering and ordering are owned by rankByQuery above;
cmdk's own filter would re-filter and reorder the
already-ranked rows. */}
<Command
className="min-h-0 flex-1"
shouldFilter={false}
value={projectActiveId ?? undefined}
onValueChange={setProjectActiveId}
>
<CommandInput
ref={projectSearchRef}
aria-label={t('chat.chatInput.draftPicker.searchProjects')}
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
value={projectQuery}
onValueChange={setProjectQuery}
onKeyDown={(event) => {
// 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,
}));
});
}}
/>
<CommandList label={t('chat.chatInput.draftPicker.projectTitle')}>
{filteredProjects.length === 0 ? (
<div role="status" className="px-3 py-6 text-center typography-ui-label text-muted-foreground">
{t('chat.chatInput.draftPicker.noProjectsFound')}
</div>
) : (
filteredProjects.map((project) => (
<CommandItem
key={project.id}
value={project.id}
onSelect={handleProjectSelect}
aria-current={project.id === selectedProject.id ? true : undefined}
className="max-w-full"
>
<span className="min-w-0 flex-1 truncate">
<ProjectLabel project={project} theme={theme} />
</span>
{project.id === selectedProject.id ? (
<Icon name="check" className="size-4 shrink-0 text-muted-foreground" />
) : null}
</CommandItem>
))
)}
</CommandList>
</Command>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
{showBranchSelector ? (
<Select
+1
View File
@@ -2185,6 +2185,7 @@ export const dict = {
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
'chat.chatInput.draftPicker.searchProjects': 'Projekte durchsuchen...',
'chat.chatInput.draftPicker.searchBranches': 'Branches durchsuchen...',
'chat.chatInput.draftPicker.noProjectsFound': 'Keine Projekte gefunden.',
'chat.chatInput.worktrees': 'Worktrees',
'chat.chatInput.worktreeNew': '+ Neu',
'chat.chatInput.drop.insertMention': 'Hier ablegen, um als Erwähnung einzufügen',
+1
View File
@@ -2406,6 +2406,7 @@ export const dict = {
'chat.chatInput.draftPicker.projectTitle': 'Project',
'chat.chatInput.draftPicker.searchProjects': 'Search projects...',
'chat.chatInput.draftPicker.searchBranches': 'Search branches...',
'chat.chatInput.draftPicker.noProjectsFound': 'No projects found.',
'chat.chatInput.worktrees': 'Worktrees',
'chat.chatInput.worktreeNew': '+ New',
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
+1
View File
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.draftPicker.projectTitle": "Proyecto",
"chat.chatInput.draftPicker.searchProjects": "Buscar proyectos...",
"chat.chatInput.draftPicker.searchBranches": "Buscar ramas...",
"chat.chatInput.draftPicker.noProjectsFound": "No se encontraron proyectos.",
"chat.chatInput.worktrees": "Worktrees",
"chat.chatInput.worktreeNew": "+ Nuevo",
"chat.chatInput.drop.insertMention": "Suelta para insertar como mención",
+1
View File
@@ -2114,6 +2114,7 @@ export const dict = {
'chat.chatInput.draftPicker.projectTitle': 'Projet',
'chat.chatInput.draftPicker.searchProjects': 'Rechercher des projets...',
'chat.chatInput.draftPicker.searchBranches': 'Rechercher des branches...',
'chat.chatInput.draftPicker.noProjectsFound': 'Aucun projet trouvé.',
'chat.chatInput.worktrees': 'Worktrees',
'chat.chatInput.worktreeNew': '+ Nouveau',
'chat.chatInput.drop.insertMention': 'Déposer pour insérer comme mention',
+1
View File
@@ -2405,6 +2405,7 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.draftPicker.projectTitle': 'プロジェクト',
'chat.chatInput.draftPicker.searchProjects': 'プロジェクトを検索...',
'chat.chatInput.draftPicker.searchBranches': 'ブランチを検索...',
'chat.chatInput.draftPicker.noProjectsFound': 'プロジェクトが見つかりません。',
'chat.chatInput.worktrees': 'ワークツリー',
'chat.chatInput.worktreeNew': '+ 新規',
'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入',
+1
View File
@@ -2406,6 +2406,7 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.draftPicker.projectTitle': '프로젝트',
'chat.chatInput.draftPicker.searchProjects': '프로젝트 검색...',
'chat.chatInput.draftPicker.searchBranches': '브랜치 검색...',
'chat.chatInput.draftPicker.noProjectsFound': '프로젝트를 찾을 수 없습니다.',
'chat.chatInput.worktrees': '워크트리',
'chat.chatInput.worktreeNew': '+ 새로 만들기',
'chat.chatInput.drop.insertMention': '여기에 놓아 멘션으로 추가',
+1
View File
@@ -1267,6 +1267,7 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...',
'chat.chatInput.draftPicker.noProjectsFound': 'Nie znaleziono projektów.',
'chat.chatInput.drop.attachFiles': 'Drop files here to attach',
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
'chat.chatInput.fileFallback': 'file',
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.draftPicker.projectTitle": "Projeto",
"chat.chatInput.draftPicker.searchProjects": "Buscar projetos...",
"chat.chatInput.draftPicker.searchBranches": "Buscar branches...",
"chat.chatInput.draftPicker.noProjectsFound": "Nenhum projeto encontrado.",
"chat.chatInput.worktrees": "Worktrees",
"chat.chatInput.worktreeNew": "+ Novo",
"chat.chatInput.drop.insertMention": "Solte para inserir como menção",
+1
View File
@@ -2346,6 +2346,7 @@ export const dict = {
'chat.chatInput.draftPicker.projectTitle': 'Proje',
'chat.chatInput.draftPicker.searchProjects': 'Projelerde ara...',
'chat.chatInput.draftPicker.searchBranches': 'Branch\'lerde ara...',
'chat.chatInput.draftPicker.noProjectsFound': 'Proje bulunamadı.',
'chat.chatInput.worktrees': 'Worktree\'ler',
'chat.chatInput.worktreeNew': '+ Yeni',
'chat.chatInput.drop.insertMention': 'Mention olarak eklemek için bırak',
+1
View File
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.draftPicker.projectTitle": "Проєкт",
"chat.chatInput.draftPicker.searchProjects": "Пошук проєктів...",
"chat.chatInput.draftPicker.searchBranches": "Пошук гілок...",
"chat.chatInput.draftPicker.noProjectsFound": "Проєктів не знайдено.",
"chat.chatInput.worktrees": "Worktree",
"chat.chatInput.worktreeNew": "+ Новий",
"chat.chatInput.drop.insertMention": "Відпустіть, щоб вставити як згадку",
@@ -2372,6 +2372,7 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.draftPicker.projectTitle': '项目',
'chat.chatInput.draftPicker.searchProjects': '搜索项目...',
'chat.chatInput.draftPicker.searchBranches': '搜索分支...',
'chat.chatInput.draftPicker.noProjectsFound': '未找到项目。',
'chat.chatInput.worktrees': '工作树',
'chat.chatInput.worktreeNew': '+ 新建',
'chat.chatInput.drop.insertMention': '释放以插入为提及',
@@ -2376,6 +2376,7 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.draftPicker.projectTitle': '專案',
'chat.chatInput.draftPicker.searchProjects': '搜尋專案...',
'chat.chatInput.draftPicker.searchBranches': '搜尋分支...',
'chat.chatInput.draftPicker.noProjectsFound': '找不到專案。',
'chat.chatInput.worktrees': 'Worktree',
'chat.chatInput.worktreeNew': '+ 新增',
'chat.chatInput.drop.insertMention': '放開以插入為提及',