From f0d4baa7256fe639553ec7ea0c7c4086c08d8a47 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 23 Jul 2026 11:56:38 -0700 Subject: [PATCH 1/2] feat(sidebar): search the workspace switcher when a user has many workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workspace-header/workspace-header.tsx | 133 +++++++++++++++++- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index a6af214e9b4..e648bc997df 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -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(null) + const searchInputRef = useRef(null) + const workspaceListRef = useRef(null) + + const [workspaceSearch, setWorkspaceSearch] = useState('') + const [highlightedId, setHighlightedId] = useState(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( + `[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()) + } }} > @@ -422,14 +494,63 @@ function WorkspaceHeaderImpl({ ) : ( <> -
- {workspaces.map((workspace) => { + {showSearch && ( + 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' + /> + )} +
+ {filteredWorkspaces.length === 0 && workspaceSearch && ( +
+ No results for "{workspaceSearch}" +
+ )} + {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 ( -
+
setHighlightedId(workspace.id) : undefined} + > {editingWorkspaceId === workspace.id ? (
Date: Thu, 23 Jul 2026 11:56:39 -0700 Subject: [PATCH 2/2] 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. --- apps/sim/components/settings/settings-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 97df881cb56..70ec8325f84 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -99,7 +99,7 @@ export function SettingsSidebar
({