Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client'

import { memo, type ReactElement, useEffect, useRef, useState } from 'react'
import { memo, type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronDown,
Chip,
ChipConfirmModal,
ChipInput,
chipGeometryClass,
chipVariants,
cn,
Expand All @@ -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'
Expand All @@ -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").
Expand Down Expand Up @@ -150,6 +154,25 @@ 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 [highlightedIndex, setHighlightedIndex] = useState(0)

const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD
const filteredWorkspaces = useMemo(() => {
const q = workspaceSearch.trim().toLowerCase()
return q ? workspaces.filter((w) => w.name.toLowerCase().includes(q)) : workspaces
}, [workspaceSearch, workspaces])

useEffect(() => {
if (!showSearch || !isWorkspaceMenuOpen) return
const el = workspaceListRef.current?.querySelector<HTMLElement>(
`[data-workspace-row-idx="${highlightedIndex}"]`
)
el?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex, showSearch, isWorkspaceMenuOpen])

const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
Expand Down Expand Up @@ -361,6 +384,13 @@ function WorkspaceHeaderImpl({
return
}
setIsWorkspaceMenuOpen(open)
if (open && showSearch) {
requestAnimationFrame(() => searchInputRef.current?.focus())
}
if (!open && showSearch) {
setWorkspaceSearch('')
setHighlightedIndex(0)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale search after menu close

Medium Severity

Search query and highlight reset only run inside onOpenChange, but the menu is often closed by calling setIsWorkspaceMenuOpen(false) directly (new workspace, invite, manage, delete, leave, context-menu dismiss). Those paths skip the reset, so a prior query can still filter the list on the next open.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 847ff5a. Configure here.

}}
>
<DropdownMenuTrigger asChild>
Expand Down Expand Up @@ -422,14 +452,57 @@ 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)
setHighlightedIndex(0)
}}
onKeyDown={(e) => {
e.stopPropagation()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Escape no longer closes menu

When the auto-focused search input receives Escape, this unconditional propagation stop prevents the key from reaching the dropdown while the handler has no Escape branch, causing the workspace switcher to remain open.

if (filteredWorkspaces.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightedIndex((i) => (i + 1) % filteredWorkspaces.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightedIndex(
(i) => (i - 1 + filteredWorkspaces.length) % filteredWorkspaces.length
)
} else if (e.key === 'Enter') {
e.preventDefault()
const target = filteredWorkspaces[highlightedIndex]
if (target) onWorkspaceSwitch(target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale highlight disables selection

When the workspace query updates while this menu is open and the filtered list shrinks below the current highlighted index, the index is not clamped. Enter then reads an out-of-range element and does nothing, leaving keyboard users unable to select the displayed result until they move the highlight or reopen the menu.

}
}}
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 === highlightedIndex

return (
<div key={workspace.id}>
<div
key={workspace.id}
data-workspace-row-idx={showSearch ? idx : undefined}
onMouseEnter={showSearch ? () => setHighlightedIndex(idx) : undefined}
>
{editingWorkspaceId === workspace.id ? (
<div
className={chipVariants({ active: true, fullWidth: true, flush: true })}
Expand Down Expand Up @@ -506,7 +579,7 @@ function WorkspaceHeaderImpl({
<div
className={cn(
chipVariants({
active: isActive || isMenuOpen,
active: isActive || isMenuOpen || isKeyboardHighlighted,
fullWidth: true,
flush: true,
}),
Expand Down
Loading