Skip to content

Commit e543000

Browse files
committed
fix(chat): prevent @-mention menu focus loss and stabilize render identity
Radix DropdownMenu's FocusScope was restoring focus from the search input to the content root whenever registered menu items mounted or unmounted inside the content, interrupting typing after a keystroke or two. - Keep the default tree always mounted under `hidden` instead of swapping subtrees when the filter activates. - Render filtered results as plain <button role="menuitem"> so they do not participate in Radix's menu Collection. - Add activeIndex state with ArrowUp/Down/Enter keyboard nav, mouse-hover sync, and scrollIntoView so the highlighted row stays visible and users can see what Enter will select. While tracing the cascade that compounded the bug: - Hoist `select` in useWorkflowMap / useWorkspacesQuery / useFolderMap to module scope so TanStack Query caches the select result across renders. - Guard setSelectedContexts([]) with a functional updater that bails out when already empty, preventing a fresh [] literal from invalidating consumers that key on reference identity. - Wrap WorkspaceHeader in React.memo so it bails out on parent renders once its (now-stable) props are unchanged. Made-with: Cursor
1 parent 1a51fa7 commit e543000

File tree

6 files changed

+145
-93
lines changed

6 files changed

+145
-93
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx

Lines changed: 124 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import React, { useCallback, useMemo, useRef, useState } from 'react'
3+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { Paperclip } from 'lucide-react'
55
import {
66
DropdownMenu,
@@ -13,6 +13,7 @@ import {
1313
DropdownMenuTrigger,
1414
} from '@/components/emcn'
1515
import { Plus, Sim } from '@/components/emcn/icons'
16+
import { cn } from '@/lib/core/utils/cn'
1617
import {
1718
buildWorkflowFolderTree,
1819
type useAvailableResources,
@@ -41,6 +42,7 @@ export const PlusMenuDropdown = React.memo(
4142
const [open, setOpen] = useState(false)
4243
const [search, setSearch] = useState('')
4344
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
45+
const [activeIndex, setActiveIndex] = useState(0)
4446
const buttonRef = useRef<HTMLButtonElement>(null)
4547
const searchRef = useRef<HTMLInputElement>(null)
4648
const contentRef = useRef<HTMLDivElement>(null)
@@ -55,6 +57,7 @@ export const PlusMenuDropdown = React.memo(
5557
}
5658
setOpen(true)
5759
setSearch('')
60+
setActiveIndex(0)
5861
}, [])
5962

6063
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
@@ -77,25 +80,48 @@ export const PlusMenuDropdown = React.memo(
7780
onResourceSelect(resource)
7881
setOpen(false)
7982
setSearch('')
83+
setActiveIndex(0)
8084
}
8185

86+
// Sync DOM scroll to the keyboard-highlighted filtered row.
87+
useEffect(() => {
88+
if (!filteredItems || filteredItems.length === 0) return
89+
const row = contentRef.current?.querySelector<HTMLElement>(
90+
`[data-filtered-idx="${activeIndex}"]`
91+
)
92+
row?.scrollIntoView({ block: 'nearest' })
93+
}, [activeIndex, filteredItems])
94+
95+
const getVisibleMenuItems = (): HTMLElement[] =>
96+
Array.from(
97+
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
98+
).filter((el) => el.offsetParent !== null)
99+
82100
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
101+
if (!filteredItems) {
102+
if (e.key === 'ArrowDown') {
103+
e.preventDefault()
104+
getVisibleMenuItems()[0]?.focus()
105+
}
106+
return
107+
}
108+
if (filteredItems.length === 0) return
83109
if (e.key === 'ArrowDown') {
84110
e.preventDefault()
85-
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
86-
firstItem?.focus()
87-
} else if (e.key === 'Enter' || e.key === 'Tab') {
111+
setActiveIndex((i) => Math.min(i + 1, filteredItems.length - 1))
112+
} else if (e.key === 'ArrowUp') {
88113
e.preventDefault()
89-
const first = filteredItems?.[0]
90-
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
114+
setActiveIndex((i) => Math.max(i - 1, 0))
115+
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
116+
e.preventDefault()
117+
const target = filteredItems[activeIndex] ?? filteredItems[0]
118+
if (target) handleSelect({ type: target.type, id: target.item.id, title: target.item.name })
91119
}
92120
}
93121

94122
const handleContentKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
95123
if (e.key === 'ArrowUp') {
96-
const items = Array.from(
97-
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
98-
)
124+
const items = getVisibleMenuItems()
99125
if (items[0] && items[0] === document.activeElement) {
100126
e.preventDefault()
101127
searchRef.current?.focus()
@@ -114,6 +140,7 @@ export const PlusMenuDropdown = React.memo(
114140
if (!isOpen) {
115141
setSearch('')
116142
setAnchorPos(null)
143+
setActiveIndex(0)
117144
onClose()
118145
}
119146
}
@@ -157,100 +184,110 @@ export const PlusMenuDropdown = React.memo(
157184
ref={searchRef}
158185
placeholder='Search resources...'
159186
value={search}
160-
onChange={(e) => setSearch(e.target.value)}
187+
onChange={(e) => {
188+
setSearch(e.target.value)
189+
setActiveIndex(0)
190+
}}
161191
onKeyDown={handleSearchKeyDown}
162192
/>
163193
<div className='min-h-0 flex-1 overflow-y-auto'>
164-
{filteredItems ? (
165-
filteredItems.length > 0 ? (
166-
filteredItems.map(({ type, item }) => {
194+
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
195+
menu FocusScope steal focus from the search input back to the content root. */}
196+
<div hidden={filteredItems !== null}>
197+
<DropdownMenuItem
198+
onClick={() => {
199+
setOpen(false)
200+
onFileSelect()
201+
}}
202+
>
203+
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
204+
<span>Attachments</span>
205+
</DropdownMenuItem>
206+
<DropdownMenuSub>
207+
<DropdownMenuSubTrigger>
208+
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
209+
<span>Workspace</span>
210+
</DropdownMenuSubTrigger>
211+
<DropdownMenuSubContent>
212+
{workflowTree.length > 0 && (
213+
<DropdownMenuSub>
214+
<DropdownMenuSubTrigger>
215+
<div
216+
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
217+
style={{
218+
backgroundColor: '#808080',
219+
borderColor: '#80808060',
220+
backgroundClip: 'padding-box',
221+
}}
222+
/>
223+
<span>Workflows</span>
224+
</DropdownMenuSubTrigger>
225+
<DropdownMenuSubContent>
226+
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
227+
</DropdownMenuSubContent>
228+
</DropdownMenuSub>
229+
)}
230+
{availableResources
231+
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
232+
.map(({ type, items }) => {
233+
if (items.length === 0) return null
234+
const config = getResourceConfig(type)
235+
const Icon = config.icon
236+
return (
237+
<DropdownMenuSub key={type}>
238+
<DropdownMenuSubTrigger>
239+
<Icon className='h-[14px] w-[14px]' />
240+
<span>{config.label}</span>
241+
</DropdownMenuSubTrigger>
242+
<DropdownMenuSubContent>
243+
{items.map((item) => (
244+
<DropdownMenuItem
245+
key={item.id}
246+
onClick={() => {
247+
handleSelect({ type, id: item.id, title: item.name })
248+
}}
249+
>
250+
{config.renderDropdownItem({ item })}
251+
</DropdownMenuItem>
252+
))}
253+
</DropdownMenuSubContent>
254+
</DropdownMenuSub>
255+
)
256+
})}
257+
</DropdownMenuSubContent>
258+
</DropdownMenuSub>
259+
</div>
260+
{/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
261+
menu Collection, or FocusScope restores focus to the content root. */}
262+
{filteredItems !== null &&
263+
(filteredItems.length > 0 ? (
264+
filteredItems.map(({ type, item }, index) => {
167265
const config = getResourceConfig(type)
266+
const isActive = index === activeIndex
168267
return (
169-
<DropdownMenuItem
268+
<button
170269
key={`${type}:${item.id}`}
270+
type='button'
271+
role='menuitem'
272+
data-filtered-idx={index}
273+
onMouseEnter={() => setActiveIndex(index)}
171274
onClick={() => {
172-
handleSelect({
173-
type,
174-
id: item.id,
175-
title: item.name,
176-
})
275+
handleSelect({ type, id: item.id, title: item.name })
177276
}}
277+
className={cn(
278+
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
279+
isActive && 'bg-[var(--surface-active)]'
280+
)}
178281
>
179282
{config.renderDropdownItem({ item })}
180-
</DropdownMenuItem>
283+
</button>
181284
)
182285
})
183286
) : (
184287
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
185288
No results
186289
</div>
187-
)
188-
) : (
189-
<>
190-
<DropdownMenuItem
191-
onClick={() => {
192-
setOpen(false)
193-
onFileSelect()
194-
}}
195-
>
196-
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
197-
<span>Attachments</span>
198-
</DropdownMenuItem>
199-
<DropdownMenuSub>
200-
<DropdownMenuSubTrigger>
201-
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
202-
<span>Workspace</span>
203-
</DropdownMenuSubTrigger>
204-
<DropdownMenuSubContent>
205-
{workflowTree.length > 0 && (
206-
<DropdownMenuSub>
207-
<DropdownMenuSubTrigger>
208-
<div
209-
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
210-
style={{
211-
backgroundColor: '#808080',
212-
borderColor: '#80808060',
213-
backgroundClip: 'padding-box',
214-
}}
215-
/>
216-
<span>Workflows</span>
217-
</DropdownMenuSubTrigger>
218-
<DropdownMenuSubContent>
219-
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
220-
</DropdownMenuSubContent>
221-
</DropdownMenuSub>
222-
)}
223-
{availableResources
224-
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
225-
.map(({ type, items }) => {
226-
if (items.length === 0) return null
227-
const config = getResourceConfig(type)
228-
const Icon = config.icon
229-
return (
230-
<DropdownMenuSub key={type}>
231-
<DropdownMenuSubTrigger>
232-
<Icon className='h-[14px] w-[14px]' />
233-
<span>{config.label}</span>
234-
</DropdownMenuSubTrigger>
235-
<DropdownMenuSubContent>
236-
{items.map((item) => (
237-
<DropdownMenuItem
238-
key={item.id}
239-
onClick={() => {
240-
handleSelect({ type, id: item.id, title: item.name })
241-
}}
242-
>
243-
{config.renderDropdownItem({ item })}
244-
</DropdownMenuItem>
245-
))}
246-
</DropdownMenuSubContent>
247-
</DropdownMenuSub>
248-
)
249-
})}
250-
</DropdownMenuSubContent>
251-
</DropdownMenuSub>
252-
</>
253-
)}
290+
))}
254291
</div>
255292
</DropdownMenuContent>
256293
</DropdownMenu>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
5858
*/
5959
useEffect(() => {
6060
if (!message) {
61-
setSelectedContexts([])
61+
// Functional updater bails out when already empty; a fresh `[]` literal would
62+
// emit a new reference and invalidate downstream memos that key on identity.
63+
setSelectedContexts((prev) => (prev.length === 0 ? prev : []))
6264
return
6365
}
6466

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
3+
import { memo, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { MoreHorizontal, Search } from 'lucide-react'
66
import {
@@ -90,7 +90,7 @@ interface WorkspaceHeaderProps {
9090
/**
9191
* Workspace header component that displays workspace name and switcher.
9292
*/
93-
export function WorkspaceHeader({
93+
function WorkspaceHeaderImpl({
9494
activeWorkspace,
9595
workspaceId,
9696
workspaces,
@@ -834,3 +834,5 @@ export function WorkspaceHeader({
834834
</div>
835835
)
836836
}
837+
838+
export const WorkspaceHeader = memo(WorkspaceHeaderImpl)

apps/sim/hooks/queries/folders.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,17 @@ export function useFolders(workspaceId?: string, options?: { scope?: FolderQuery
5656
})
5757
}
5858

59+
const selectFolderMap = (folders: WorkflowFolder[]): Record<string, WorkflowFolder> =>
60+
Object.fromEntries(folders.map((folder) => [folder.id, folder]))
61+
5962
export function useFolderMap(workspaceId?: string) {
6063
return useQuery({
6164
queryKey: folderKeys.list(workspaceId),
6265
queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal),
6366
enabled: Boolean(workspaceId),
6467
placeholderData: keepPreviousData,
6568
staleTime: 60 * 1000,
66-
select: (folders) => Object.fromEntries(folders.map((folder) => [folder.id, folder])),
69+
select: selectFolderMap,
6770
})
6871
}
6972

apps/sim/hooks/queries/workflows.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQ
6969
})
7070
}
7171

72+
// Module-scoped so TanStack Query caches the select result by function identity;
73+
// an inline arrow would return a new reference every render.
74+
const selectWorkflowMap = (data: WorkflowMetadata[]): Record<string, WorkflowMetadata> =>
75+
Object.fromEntries(data.map((w) => [w.id, w]))
76+
7277
/**
7378
* Returns workflows as a `Record<string, WorkflowMetadata>` keyed by ID.
7479
* Uses the `select` option so the transformation runs inside React Query
@@ -82,7 +87,7 @@ export function useWorkflowMap(workspaceId?: string, options?: { scope?: Workflo
8287
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
8388
placeholderData: keepPreviousData,
8489
staleTime: WORKFLOW_LIST_STALE_TIME,
85-
select: (data) => Object.fromEntries(data.map((w) => [w.id, w])),
90+
select: selectWorkflowMap,
8691
})
8792
}
8893

apps/sim/hooks/queries/workspace.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ async function fetchWorkspaces(
5555
}
5656
}
5757

58+
// Module-scoped so TanStack Query caches the select result across renders.
59+
const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces
60+
5861
/**
5962
* Fetches the current user's workspaces.
6063
* Returns only the workspace array. Use `useWorkspacesWithMetadata` when
@@ -64,7 +67,7 @@ export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope =
6467
return useQuery({
6568
queryKey: workspaceKeys.list(scope),
6669
queryFn: ({ signal }) => fetchWorkspaces(scope, signal),
67-
select: (data) => data.workspaces,
70+
select: selectWorkspaces,
6871
enabled,
6972
staleTime: 30 * 1000,
7073
placeholderData: keepPreviousData,

0 commit comments

Comments
 (0)