Skip to content

Commit 4479c4f

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. All the search 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 highlight tracks the highlighted workspace by identity (a stored id, not a numeric position), so a live list change while the menu is open — shrink, grow, or reorder from a membership change or background refetch — keeps the highlight and Enter on the same workspace; a filtered-out or absent selection falls back to the first row. `activeIndex` derives from that id and is the single source of truth for Enter, the visual highlight, and the scroll target. 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. Hover-highlight is wired to mousemove rather than mouseenter so a keyboard-driven scrollIntoView can't fire a synthetic enter that hijacks the keyboard selection.
1 parent efe1de3 commit 4479c4f

1 file changed

Lines changed: 115 additions & 6 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: 115 additions & 6 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,57 @@ 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 [highlightedId, setHighlightedId] = useState<string | null>(null)
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 resolved from the highlighted workspace's identity, not
172+
* a stored position. Tracking the id (rather than a numeric index) keeps the
173+
* highlight on the same workspace when the list shrinks, grows, or reorders
174+
* while the menu is open (a live membership change or background refetch);
175+
* a missing id (filtered out) or no selection falls back to the first row.
176+
* `activeIndex` is the single source of truth for Enter, the visual highlight,
177+
* and the scroll target, so those three can never diverge.
178+
*/
179+
const activeIndex = highlightedId
180+
? Math.max(
181+
0,
182+
filteredWorkspaces.findIndex((w) => w.id === highlightedId)
183+
)
184+
: 0
185+
186+
useEffect(() => {
187+
if (!showSearch || !isWorkspaceMenuOpen) return
188+
const el = workspaceListRef.current?.querySelector<HTMLElement>(
189+
`[data-workspace-row-idx="${activeIndex}"]`
190+
)
191+
el?.scrollIntoView({ block: 'nearest' })
192+
}, [activeIndex, showSearch, isWorkspaceMenuOpen])
193+
194+
/**
195+
* Clear the query and highlight whenever the menu closes, by any path —
196+
* selecting a workspace closes it via `setIsWorkspaceMenuOpen(false)` without
197+
* routing through `onOpenChange`, so resetting here (not in the open handler)
198+
* keeps a stale search from persisting into the next open. Not gated on
199+
* `showSearch`: if the list drops to the threshold while a query is active the
200+
* search input unmounts, and this still clears the now-invisible filter. For
201+
* users who never search, both setters no-op (same value) so there is no cost.
202+
*/
203+
useEffect(() => {
204+
if (isWorkspaceMenuOpen) return
205+
setWorkspaceSearch('')
206+
setHighlightedId(null)
207+
}, [isWorkspaceMenuOpen])
153208

154209
const [isMounted, setIsMounted] = useState(false)
155210
useEffect(() => {
@@ -361,6 +416,9 @@ function WorkspaceHeaderImpl({
361416
return
362417
}
363418
setIsWorkspaceMenuOpen(open)
419+
if (open && showSearch) {
420+
requestAnimationFrame(() => searchInputRef.current?.focus())
421+
}
364422
}}
365423
>
366424
<DropdownMenuTrigger asChild>
@@ -422,14 +480,65 @@ function WorkspaceHeaderImpl({
422480
</div>
423481
) : (
424482
<>
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) => {
483+
{showSearch && (
484+
<ChipInput
485+
ref={searchInputRef}
486+
icon={Search}
487+
placeholder='Search workspaces...'
488+
value={workspaceSearch}
489+
onChange={(e) => {
490+
setWorkspaceSearch(e.target.value)
491+
setHighlightedId(null)
492+
}}
493+
onKeyDown={(e) => {
494+
e.stopPropagation()
495+
if (filteredWorkspaces.length === 0) return
496+
if (e.key === 'ArrowDown') {
497+
e.preventDefault()
498+
const next = (activeIndex + 1) % filteredWorkspaces.length
499+
setHighlightedId(filteredWorkspaces[next].id)
500+
} else if (e.key === 'ArrowUp') {
501+
e.preventDefault()
502+
const next =
503+
(activeIndex - 1 + filteredWorkspaces.length) % filteredWorkspaces.length
504+
setHighlightedId(filteredWorkspaces[next].id)
505+
} else if (e.key === 'Enter') {
506+
e.preventDefault()
507+
const target = filteredWorkspaces[activeIndex]
508+
if (target) onWorkspaceSwitch(target)
509+
}
510+
}}
511+
className='mb-1.5'
512+
/>
513+
)}
514+
<div
515+
ref={workspaceListRef}
516+
className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'
517+
>
518+
{filteredWorkspaces.length === 0 && workspaceSearch && (
519+
<div className='px-2 py-[5px] text-[var(--text-muted)] text-caption'>
520+
No results for "{workspaceSearch}"
521+
</div>
522+
)}
523+
{filteredWorkspaces.map((workspace, idx) => {
427524
const initial = getWorkspaceInitial(workspace.name)
428525
const isActive = workspace.id === workspaceId
429526
const isMenuOpen = menuOpenWorkspaceId === workspace.id
430-
527+
const isKeyboardHighlighted = showSearch && idx === activeIndex
528+
529+
/**
530+
* Hover-highlight is wired to `onMouseMove`, not `onMouseEnter`: a
531+
* keyboard-driven `scrollIntoView` slides rows under a stationary cursor
532+
* and fires `mouseenter`, which would hijack the keyboard selection.
533+
* `mousemove` only fires on real pointer motion, so hover follows the
534+
* mouse without fighting the arrow keys.
535+
*/
431536
return (
432-
<div key={workspace.id}>
537+
<div
538+
key={workspace.id}
539+
data-workspace-row-idx={showSearch ? idx : undefined}
540+
onMouseMove={showSearch ? () => setHighlightedId(workspace.id) : undefined}
541+
>
433542
{editingWorkspaceId === workspace.id ? (
434543
<div
435544
className={chipVariants({ active: true, fullWidth: true, flush: true })}
@@ -506,7 +615,7 @@ function WorkspaceHeaderImpl({
506615
<div
507616
className={cn(
508617
chipVariants({
509-
active: isActive || isMenuOpen,
618+
active: isActive || isMenuOpen || isKeyboardHighlighted,
510619
fullWidth: true,
511620
flush: true,
512621
}),

0 commit comments

Comments
 (0)