@@ -3,13 +3,12 @@ import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDr
33import { resolveEnvironmentMachineKind , type ScopedProjectRef } from "@t3tools/contracts" ;
44import { scopedProjectKey , scopeProjectRef } from "@t3tools/client-runtime/environment" ;
55import { isScratchProject } from "@t3tools/client-runtime/state/projects" ;
6- import { FolderPlusIcon , MessageSquareDashedIcon } from "lucide-react" ;
6+ import { FolderPlusIcon } from "lucide-react" ;
77import { useAtomValue } from "@effect/atom-react" ;
8- import { useCallback , useEffect , useMemo , useRef } from "react" ;
8+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
99
1010import { openCommandPalette } from "~/commandPaletteBus" ;
1111import { shortcutLabelForCommand } from "~/keybindings" ;
12- import { projectIconColorClassName } from "~/projectIconColors" ;
1312import { primaryServerKeybindingsAtom } from "~/state/server" ;
1413import { useNewThreadHandler } from "~/hooks/useHandleNewThread" ;
1514import { useScratchProject } from "~/hooks/useScratchProject" ;
@@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
3029import { ProjectFavicon } from "../ProjectFavicon" ;
3130import { sortLogicalProjectsForSidebar } from "../Sidebar.logic" ;
3231import {
33- Menu ,
34- MenuItem ,
35- MenuPopup ,
36- MenuRadioGroup ,
37- MenuRadioItem ,
38- MenuSeparator ,
39- MenuTrigger ,
40- } from "../ui/menu" ;
32+ Combobox ,
33+ ComboboxEmpty ,
34+ ComboboxItem ,
35+ ComboboxList ,
36+ ComboboxPopup ,
37+ ComboboxSearchInput ,
38+ ComboboxTrigger ,
39+ useComboboxFilter ,
40+ } from "../ui/combobox" ;
4141import { Tooltip , TooltipPopup , TooltipTrigger } from "../ui/tooltip" ;
4242import { InlineButton } from "../ui/button" ;
4343import { resolveProjectSettings } from "@t3tools/shared/projectSettings" ;
4444
45- // Menu value for "No project"; real entries are keyed by logical project key.
46- const NO_PROJECT_VALUE = "no-project" ;
45+ // Picker value for the "Add project" row; real entries are keyed by logical
46+ // project key.
47+ const ADD_PROJECT_VALUE = "add-project" ;
48+
49+ interface PickerItem {
50+ readonly value : string ;
51+ readonly label : string ;
52+ }
4753
4854interface DraftHeroHeadlineProps {
4955 readonly draftId : DraftId | null ;
@@ -147,11 +153,15 @@ export function DraftHeroHeadline({
147153 const hasResolvedProject = activeProjectTitle !== null ;
148154 const canChooseProject = projectPickerEntries . length > 0 ;
149155 const shouldShowProjectMenu = canChooseProject ;
150- // The project that hosts threads without a project appears once, as the
151- // "No project" item, not as a project row.
152- const menuEntries = projectPickerEntries . filter (
153- ( { targetProject } ) =>
154- ! isScratchProject ( targetProject , scratchWorkspaceRootFor ( targetProject . environmentId ) ) ,
156+ // The project that hosts threads without a project is not a row: the line
157+ // under the headline is the way into it.
158+ const menuEntries = useMemo (
159+ ( ) =>
160+ projectPickerEntries . filter (
161+ ( { targetProject } ) =>
162+ ! isScratchProject ( targetProject , scratchWorkspaceRootFor ( targetProject . environmentId ) ) ,
163+ ) ,
164+ [ projectPickerEntries , scratchWorkspaceRootFor ] ,
155165 ) ;
156166 const activeProject =
157167 activeProjectRef === null
@@ -168,6 +178,30 @@ export function DraftHeroHeadline({
168178 const isScratchDraft =
169179 activeProject !== null && isScratchProject ( activeProject , scratchWorkspaceRoot ) ;
170180
181+ // {value, label} items let Base UI drive the combobox selection while the
182+ // popup search filters the same collection. "Add project" is an action, not
183+ // a project, so it only trails the unfiltered list.
184+ const pickerItems = useMemo < readonly PickerItem [ ] > (
185+ ( ) => [
186+ ...menuEntries . map ( ( { group } ) => ( { value : group . projectKey , label : group . displayName } ) ) ,
187+ { value : ADD_PROJECT_VALUE , label : "Add project" } ,
188+ ] ,
189+ [ menuEntries ] ,
190+ ) ;
191+ const [ pickerOpen , setPickerOpen ] = useState ( false ) ;
192+ const [ pickerQuery , setPickerQuery ] = useState ( "" ) ;
193+ const pickerFilter = useComboboxFilter ( ) ;
194+ const filteredPickerItems = useMemo ( ( ) => {
195+ const query = pickerQuery . trim ( ) ;
196+ if ( query . length === 0 ) return pickerItems ;
197+ return pickerItems . filter (
198+ ( item ) =>
199+ item . value !== ADD_PROJECT_VALUE &&
200+ pickerFilter . contains ( item , query , ( candidate ) => candidate . label ) ,
201+ ) ;
202+ } , [ pickerFilter , pickerItems , pickerQuery ] ) ;
203+ const selectedPickerItem = pickerItems . find ( ( item ) => item . value === activeProjectKey ) ?? null ;
204+
171205 // The picker can change the draft's target while the no-project home is
172206 // still being opened; a stale continuation must not retarget it again.
173207 const latestTargetRef = useRef ( { draftId, activeProjectKey, scratchTargetEnvironmentId } ) ;
@@ -234,15 +268,40 @@ export function DraftHeroHeadline({
234268 } ;
235269
236270 const projectSelector = shouldShowProjectMenu ? (
237- < Menu >
271+ < Combobox
272+ items = { pickerItems }
273+ filteredItems = { filteredPickerItems }
274+ autoHighlight
275+ itemToStringLabel = { ( item ) => item . label }
276+ isItemEqualToValue = { ( a , b ) => a . value === b . value }
277+ open = { pickerOpen }
278+ onOpenChange = { ( open ) => {
279+ setPickerOpen ( open ) ;
280+ setPickerQuery ( "" ) ;
281+ } }
282+ value = { selectedPickerItem }
283+ onValueChange = { ( item ) => {
284+ if ( ! item ) return ;
285+ setPickerOpen ( false ) ;
286+ if ( item . value === ADD_PROJECT_VALUE ) {
287+ openAddProject ( ) ;
288+ return ;
289+ }
290+ const entry = projectEntryByKey . get ( item . value ) ;
291+ if ( ! entry || item . value === activeProjectKey ) {
292+ return ;
293+ }
294+ selectProject ( entry . targetProject , entry . group . projectKey ) ;
295+ } }
296+ >
238297 < Tooltip >
239298 < TooltipTrigger
240299 render = {
241300 // The trigger's accessible name comes from its visible text (the
242301 // project title) so the hero sentence reads naturally: an
243302 // aria-label here would replace the title with an action phrase
244303 // mid-sentence and baffle screen-reader users.
245- < MenuTrigger
304+ < ComboboxTrigger
246305 render = { < InlineButton tone = "picker" /> }
247306 data-draft-project-trigger = ""
248307 className = "pointer-events-auto max-w-64 align-baseline"
@@ -257,65 +316,43 @@ export function DraftHeroHeadline({
257316 < TooltipPopup side = "top" > { activeProjectDisplayName } </ TooltipPopup >
258317 ) : null }
259318 </ Tooltip >
260- < MenuPopup align = "center" className = "max-h-80 overflow-y-auto" >
261- < MenuRadioGroup
262- value = { isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey }
263- onValueChange = { ( value ) => {
264- if ( value === NO_PROJECT_VALUE ) {
265- void startScratch ( ) ;
266- return ;
267- }
268- const entry = projectEntryByKey . get ( value as string ) ;
269- if ( ! entry || value === activeProjectKey ) {
270- return ;
271- }
272- selectProject ( entry . targetProject , entry . group . projectKey ) ;
273- } }
274- >
275- { scratchWorkspaceRoot === null ? null : (
276- < MenuRadioItem value = { NO_PROJECT_VALUE } closeOnClick >
277- < span className = "flex min-w-0 items-center gap-2" >
278- { /* Boxed like ProjectFavicon so the label lines up with project rows. */ }
279- < span
280- aria-hidden = "true"
281- className = { `inline-flex size-4 shrink-0 ${ projectIconColorClassName ( "gray" ) } ` }
282- >
283- < MessageSquareDashedIcon className = "size-full" />
284- </ span >
285- No project
286- </ span >
287- </ MenuRadioItem >
288- ) }
289- { menuEntries . map ( ( { group } ) => {
319+ < ComboboxPopup align = "center" className = "w-72 overflow-hidden" >
320+ < ComboboxSearchInput
321+ aria-label = "Search projects"
322+ placeholder = "Search projects..."
323+ value = { pickerQuery }
324+ onChange = { ( event ) => setPickerQuery ( event . target . value ) }
325+ />
326+ < ComboboxEmpty > No matching projects.</ ComboboxEmpty >
327+ < ComboboxList >
328+ { ( item : PickerItem ) => {
329+ const entry = projectEntryByKey . get ( item . value ) ;
290330 return (
291- < MenuRadioItem key = { group . projectKey } value = { group . projectKey } closeOnClick >
292- < span className = "flex min-w-0 items-center gap-2" >
293- < ProjectFavicon project = { group } className = "size-4 shrink-0" />
294- < Tooltip >
295- < TooltipTrigger render = { < span className = "block min-w-0 truncate" /> } >
296- { group . displayName }
297- </ TooltipTrigger >
298- < TooltipPopup side = "top" > { group . displayName } </ TooltipPopup >
299- </ Tooltip >
300- { showProjectEnvironments ? (
301- < ProjectEnvironmentBadge
302- group = { group }
303- primaryEnvironmentId = { primaryEnvironmentId }
304- machineByEnvironmentId = { environmentMachineById }
305- />
306- ) : null }
307- </ span >
308- </ MenuRadioItem >
331+ < ComboboxItem key = { item . value } hideIndicator value = { item } >
332+ { item . value === ADD_PROJECT_VALUE ? (
333+ < FolderPlusIcon className = "size-4 shrink-0" />
334+ ) : entry ? (
335+ < ProjectFavicon project = { entry . group } className = "size-4 shrink-0" />
336+ ) : null }
337+ < Tooltip >
338+ < TooltipTrigger render = { < span className = "min-w-0 flex-1 truncate text-sm" /> } >
339+ { item . label }
340+ </ TooltipTrigger >
341+ < TooltipPopup side = "top" > { item . label } </ TooltipPopup >
342+ </ Tooltip >
343+ { entry && showProjectEnvironments ? (
344+ < ProjectEnvironmentBadge
345+ group = { entry . group }
346+ primaryEnvironmentId = { primaryEnvironmentId }
347+ machineByEnvironmentId = { environmentMachineById }
348+ />
349+ ) : null }
350+ </ ComboboxItem >
309351 ) ;
310- } ) }
311- </ MenuRadioGroup >
312- { projectPickerEntries . length > 0 ? < MenuSeparator /> : null }
313- < MenuItem onClick = { openAddProject } >
314- < FolderPlusIcon />
315- Add project
316- </ MenuItem >
317- </ MenuPopup >
318- </ Menu >
352+ } }
353+ </ ComboboxList >
354+ </ ComboboxPopup >
355+ </ Combobox >
319356 ) : (
320357 < button
321358 type = "button"
0 commit comments