55 ChevronDown ,
66 Chip ,
77 ChipConfirmModal ,
8+ ChipInput ,
89 chipGeometryClass ,
910 chipVariants ,
1011 cn ,
@@ -19,7 +20,7 @@ import {
1920} from '@sim/emcn'
2021import { ManageWorkspace , PanelLeft } from '@sim/emcn/icons'
2122import { createLogger } from '@sim/logger'
22- import { MoreHorizontal } from 'lucide-react'
23+ import { MoreHorizontal , Search } from 'lucide-react'
2324import { useActiveOrganization } from '@/lib/auth/auth-client'
2425import { isBillingEnabled } from '@/lib/core/config/env-flags'
2526import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -35,6 +36,9 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
3536
3637const logger = createLogger ( 'WorkspaceHeader' )
3738
39+ /** Show the search input once the workspace list exceeds this count. */
40+ const WORKSPACE_SEARCH_THRESHOLD = 3
41+
3842/**
3943 * Derives the single-letter avatar initial for a workspace, ignoring the word
4044 * "workspace" in the name (e.g. "Acme Workspace" → "A").
@@ -150,6 +154,49 @@ function WorkspaceHeaderImpl({
150154 const contextMenuClosedRef = useRef ( true )
151155 const hasInputFocusedRef = useRef ( false )
152156 const renameInputRef = useRef < HTMLInputElement | null > ( null )
157+ const searchInputRef = useRef < HTMLInputElement > ( null )
158+ const workspaceListRef = useRef < HTMLDivElement > ( null )
159+
160+ const [ workspaceSearch , setWorkspaceSearch ] = useState ( '' )
161+ const [ highlightedIndex , setHighlightedIndex ] = useState ( 0 )
162+
163+ const showSearch = workspaces . length > WORKSPACE_SEARCH_THRESHOLD
164+ const searchQuery = workspaceSearch . trim ( ) . toLowerCase ( )
165+ const filteredWorkspaces =
166+ showSearch && searchQuery
167+ ? workspaces . filter ( ( w ) => w . name . toLowerCase ( ) . includes ( searchQuery ) )
168+ : workspaces
169+
170+ /**
171+ * The highlighted row reconciled against the current results. The stored
172+ * index can go stale if the workspace list mutates while the menu is open
173+ * (a live membership change, a background refetch), so clamp it on read —
174+ * keyboard selection and the visual highlight always point at a real row.
175+ */
176+ const activeIndex = highlightedIndex < filteredWorkspaces . length ? highlightedIndex : 0
177+
178+ useEffect ( ( ) => {
179+ if ( ! showSearch || ! isWorkspaceMenuOpen ) return
180+ const el = workspaceListRef . current ?. querySelector < HTMLElement > (
181+ `[data-workspace-row-idx="${ activeIndex } "]`
182+ )
183+ el ?. scrollIntoView ( { block : 'nearest' } )
184+ } , [ activeIndex , showSearch , isWorkspaceMenuOpen ] )
185+
186+ /**
187+ * Clear the query and highlight whenever the menu closes, by any path —
188+ * selecting a workspace closes it via `setIsWorkspaceMenuOpen(false)` without
189+ * routing through `onOpenChange`, so resetting here (not in the open handler)
190+ * keeps a stale search from persisting into the next open. Not gated on
191+ * `showSearch`: if the list drops to the threshold while a query is active the
192+ * search input unmounts, and this still clears the now-invisible filter. For
193+ * users who never search, both setters no-op (same value) so there is no cost.
194+ */
195+ useEffect ( ( ) => {
196+ if ( isWorkspaceMenuOpen ) return
197+ setWorkspaceSearch ( '' )
198+ setHighlightedIndex ( 0 )
199+ } , [ isWorkspaceMenuOpen ] )
153200
154201 const [ isMounted , setIsMounted ] = useState ( false )
155202 useEffect ( ( ) => {
@@ -361,6 +408,9 @@ function WorkspaceHeaderImpl({
361408 return
362409 }
363410 setIsWorkspaceMenuOpen ( open )
411+ if ( open && showSearch ) {
412+ requestAnimationFrame ( ( ) => searchInputRef . current ?. focus ( ) )
413+ }
364414 } }
365415 >
366416 < DropdownMenuTrigger asChild >
@@ -422,14 +472,57 @@ function WorkspaceHeaderImpl({
422472 </ div >
423473 ) : (
424474 < >
425- < div className = '-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5' >
426- { workspaces . map ( ( workspace ) => {
475+ { showSearch && (
476+ < ChipInput
477+ ref = { searchInputRef }
478+ icon = { Search }
479+ placeholder = 'Search workspaces...'
480+ value = { workspaceSearch }
481+ onChange = { ( e ) => {
482+ setWorkspaceSearch ( e . target . value )
483+ setHighlightedIndex ( 0 )
484+ } }
485+ onKeyDown = { ( e ) => {
486+ e . stopPropagation ( )
487+ if ( filteredWorkspaces . length === 0 ) return
488+ if ( e . key === 'ArrowDown' ) {
489+ e . preventDefault ( )
490+ setHighlightedIndex ( ( activeIndex + 1 ) % filteredWorkspaces . length )
491+ } else if ( e . key === 'ArrowUp' ) {
492+ e . preventDefault ( )
493+ setHighlightedIndex (
494+ ( activeIndex - 1 + filteredWorkspaces . length ) % filteredWorkspaces . length
495+ )
496+ } else if ( e . key === 'Enter' ) {
497+ e . preventDefault ( )
498+ const target = filteredWorkspaces [ activeIndex ]
499+ if ( target ) onWorkspaceSwitch ( target )
500+ }
501+ } }
502+ className = 'mb-1.5'
503+ />
504+ ) }
505+ < div
506+ ref = { workspaceListRef }
507+ className = '-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'
508+ >
509+ { filteredWorkspaces . length === 0 && workspaceSearch && (
510+ < div className = 'px-2 py-[5px] text-[var(--text-muted)] text-caption' >
511+ No results for "{ workspaceSearch } "
512+ </ div >
513+ ) }
514+ { filteredWorkspaces . map ( ( workspace , idx ) => {
427515 const initial = getWorkspaceInitial ( workspace . name )
428516 const isActive = workspace . id === workspaceId
429517 const isMenuOpen = menuOpenWorkspaceId === workspace . id
518+ const isKeyboardHighlighted = showSearch && idx === activeIndex
430519
431520 return (
432- < div key = { workspace . id } >
521+ < div
522+ key = { workspace . id }
523+ data-workspace-row-idx = { showSearch ? idx : undefined }
524+ onMouseEnter = { showSearch ? ( ) => setHighlightedIndex ( idx ) : undefined }
525+ >
433526 { editingWorkspaceId === workspace . id ? (
434527 < div
435528 className = { chipVariants ( { active : true , fullWidth : true , flush : true } ) }
@@ -506,7 +599,7 @@ function WorkspaceHeaderImpl({
506599 < div
507600 className = { cn (
508601 chipVariants ( {
509- active : isActive || isMenuOpen ,
602+ active : isActive || isMenuOpen || isKeyboardHighlighted ,
510603 fullWidth : true ,
511604 flush : true ,
512605 } ) ,
0 commit comments