Skip to content

Commit 19246d2

Browse files
committed
feat(sidebar): search the workspace switcher when a user has many workspaces
Shows a search input in the workspace dropdown once the list exceeds WORKSPACE_SEARCH_THRESHOLD (3). ArrowUp/Down move through results, Enter switches, and the query resets on close. The highlight/reset/scroll machinery is gated on showSearch so users with few workspaces get no extra re-renders. The input reuses the emcn ChipInput chrome (icon prop) rather than hand-rolling the field. The highlighted index is clamped against the current results on read, so a live workspace-list change while the menu is open can't leave keyboard selection pointing at a stale or out-of-range row. Search and highlight reset via an effect keyed on the menu-open state, so closing by any path (selecting a workspace, Escape, click-away) clears them — not only the Radix-driven close that routes through onOpenChange.
1 parent efe1de3 commit 19246d2

1 file changed

Lines changed: 98 additions & 5 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ChevronDown,
66
Chip,
77
ChipConfirmModal,
8+
ChipInput,
89
chipGeometryClass,
910
chipVariants,
1011
cn,
@@ -19,7 +20,7 @@ import {
1920
} from '@sim/emcn'
2021
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
2122
import { createLogger } from '@sim/logger'
22-
import { MoreHorizontal } from 'lucide-react'
23+
import { MoreHorizontal, Search } from 'lucide-react'
2324
import { useActiveOrganization } from '@/lib/auth/auth-client'
2425
import { isBillingEnabled } from '@/lib/core/config/env-flags'
2526
import { 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

3637
const 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

Comments
 (0)