feat(sidebar): search the workspace switcher when a user has many workspaces (#5893)

* 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). An effect keeps that id pinned to a workspace that is actually
in the current results — seeding the first result on open and re-seeding when a
query filters the current one out — so even the default highlight is identity-
stable. A live list change while the menu is open (shrink, grow, or reorder from
a membership change or background refetch) therefore carries the highlight and
Enter along with the same workspace instead of stranding them on whatever now
sits at that row. `activeIndex` derives from the 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, and composition keys are
ignored while an IME is active so confirming a candidate can't switch workspace.

* fix(settings): pad settings-sidebar scroll list so the last item's hover isn't clipped

The scrollable settings-nav container had `pt-1.5` but no bottom padding, so
the last item's rounded `--surface-active` hover pill was clipped against the
container's overflow edge (visible on "Custom blocks", the final Enterprise
item). Give it symmetric vertical padding (`py-1.5`) so the last row gets
equal breathing room. Padding sits on the scroll container, not between
items, so item spacing is unchanged.
This commit is contained in:
Waleed
2026-07-23 12:02:31 -07:00
committed by GitHub
parent c8fea6bd3b
commit d39b193201
2 changed files with 127 additions and 6 deletions
@@ -5,6 +5,7 @@ import {
ChevronDown,
Chip,
ChipConfirmModal,
ChipInput,
chipGeometryClass,
chipVariants,
cn,
@@ -19,7 +20,7 @@ import {
} from '@sim/emcn'
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { MoreHorizontal } from 'lucide-react'
import { MoreHorizontal, Search } from 'lucide-react'
import { useActiveOrganization } from '@/lib/auth/auth-client'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
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'
const logger = createLogger('WorkspaceHeader')
/** Show the search input once the workspace list exceeds this count. */
const WORKSPACE_SEARCH_THRESHOLD = 3
/**
* Derives the single-letter avatar initial for a workspace, ignoring the word
* "workspace" in the name (e.g. "Acme Workspace" → "A").
@@ -150,6 +154,71 @@ function WorkspaceHeaderImpl({
const contextMenuClosedRef = useRef(true)
const hasInputFocusedRef = useRef(false)
const renameInputRef = useRef<HTMLInputElement | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const workspaceListRef = useRef<HTMLDivElement>(null)
const [workspaceSearch, setWorkspaceSearch] = useState('')
const [highlightedId, setHighlightedId] = useState<string | null>(null)
const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD
const searchQuery = workspaceSearch.trim().toLowerCase()
const filteredWorkspaces =
showSearch && searchQuery
? workspaces.filter((w) => w.name.toLowerCase().includes(searchQuery))
: workspaces
/**
* The highlighted row resolved from the highlighted workspace's identity, not
* a stored position. Tracking the id (rather than a numeric index) keeps the
* highlight on the same workspace when the list shrinks, grows, or reorders
* while the menu is open (a live membership change or background refetch);
* a missing id (filtered out) or no selection falls back to the first row.
* `activeIndex` is the single source of truth for Enter, the visual highlight,
* and the scroll target, so those three can never diverge.
*/
const activeIndex = highlightedId
? Math.max(
0,
filteredWorkspaces.findIndex((w) => w.id === highlightedId)
)
: 0
useEffect(() => {
if (!showSearch || !isWorkspaceMenuOpen) return
const el = workspaceListRef.current?.querySelector<HTMLElement>(
`[data-workspace-row-idx="${activeIndex}"]`
)
el?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, showSearch, isWorkspaceMenuOpen])
/**
* Seed the highlight to the first result whenever the current one is absent —
* on open, or after typing filters the highlighted workspace out. This keeps
* `highlightedId` pinned to a real workspace identity rather than falling back
* to a bare positional default, so a reorder or query change carries the
* highlight along with its workspace instead of stranding it on whatever now
* occupies the first row.
*/
useEffect(() => {
if (!showSearch || !isWorkspaceMenuOpen || filteredWorkspaces.length === 0) return
const present = highlightedId !== null && filteredWorkspaces.some((w) => w.id === highlightedId)
if (!present) setHighlightedId(filteredWorkspaces[0].id)
}, [highlightedId, filteredWorkspaces, showSearch, isWorkspaceMenuOpen])
/**
* Clear the query and highlight whenever the menu closes, by any path —
* selecting a workspace closes it via `setIsWorkspaceMenuOpen(false)` without
* routing through `onOpenChange`, so resetting here (not in the open handler)
* keeps a stale search from persisting into the next open. Not gated on
* `showSearch`: if the list drops to the threshold while a query is active the
* search input unmounts, and this still clears the now-invisible filter. For
* users who never search, both setters no-op (same value) so there is no cost.
*/
useEffect(() => {
if (isWorkspaceMenuOpen) return
setWorkspaceSearch('')
setHighlightedId(null)
}, [isWorkspaceMenuOpen])
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
@@ -361,6 +430,9 @@ function WorkspaceHeaderImpl({
return
}
setIsWorkspaceMenuOpen(open)
if (open && showSearch) {
requestAnimationFrame(() => searchInputRef.current?.focus())
}
}}
>
<DropdownMenuTrigger asChild>
@@ -422,14 +494,63 @@ function WorkspaceHeaderImpl({
</div>
) : (
<>
<div className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'>
{workspaces.map((workspace) => {
{showSearch && (
<ChipInput
ref={searchInputRef}
icon={Search}
placeholder='Search workspaces...'
value={workspaceSearch}
onChange={(e) => setWorkspaceSearch(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.nativeEvent.isComposing) return
if (filteredWorkspaces.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = (activeIndex + 1) % filteredWorkspaces.length
setHighlightedId(filteredWorkspaces[next].id)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const next =
(activeIndex - 1 + filteredWorkspaces.length) % filteredWorkspaces.length
setHighlightedId(filteredWorkspaces[next].id)
} else if (e.key === 'Enter') {
e.preventDefault()
const target = filteredWorkspaces[activeIndex]
if (target) onWorkspaceSwitch(target)
}
}}
className='mb-1.5'
/>
)}
<div
ref={workspaceListRef}
className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'
>
{filteredWorkspaces.length === 0 && workspaceSearch && (
<div className='px-2 py-[5px] text-[var(--text-muted)] text-caption'>
No results for "{workspaceSearch}"
</div>
)}
{filteredWorkspaces.map((workspace, idx) => {
const initial = getWorkspaceInitial(workspace.name)
const isActive = workspace.id === workspaceId
const isMenuOpen = menuOpenWorkspaceId === workspace.id
const isKeyboardHighlighted = showSearch && idx === activeIndex
/**
* Hover-highlight is wired to `onMouseMove`, not `onMouseEnter`: a
* keyboard-driven `scrollIntoView` slides rows under a stationary cursor
* and fires `mouseenter`, which would hijack the keyboard selection.
* `mousemove` only fires on real pointer motion, so hover follows the
* mouse without fighting the arrow keys.
*/
return (
<div key={workspace.id}>
<div
key={workspace.id}
data-workspace-row-idx={showSearch ? idx : undefined}
onMouseMove={showSearch ? () => setHighlightedId(workspace.id) : undefined}
>
{editingWorkspaceId === workspace.id ? (
<div
className={chipVariants({ active: true, fullWidth: true, flush: true })}
@@ -506,7 +627,7 @@ function WorkspaceHeaderImpl({
<div
className={cn(
chipVariants({
active: isActive || isMenuOpen,
active: isActive || isMenuOpen || isKeyboardHighlighted,
fullWidth: true,
flush: true,
}),
@@ -99,7 +99,7 @@ export function SettingsSidebar<Section extends SettingsSection>({
<div
ref={isCollapsed ? undefined : scrollContainerRef}
className={cn(
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-1.5 transition-colors duration-150',
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t py-1.5 transition-colors duration-150',
!hasOverflowTop && 'border-transparent'
)}
>