fix(chat): prevent @-mention menu focus loss and stabilize render identity (#4218)

* fix(docs): preserve gif playback position in lightbox and clean up ui components

- Capture currentTime on click and seek lightbox video to match using useLayoutEffect
- Convert lightboxStartTime from useState to useRef (no independent render needed)
- Apply same fix to ActionVideo in action-media.tsx
- Remove dead AnimatedBlocks component (zero imports)
- Fix language-dropdown to derive currentLang during render instead of mirroring into state via effect
- Replace template literals with cn() in faq.tsx and video.tsx

* 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

* remove extraneous comments

* cleanup

* fix(chat): apply same setState bail-out to clearContexts for consistency

Matches the invariant we already established for the message effect:
calling setSelectedContexts([]) against an already-empty array emits a
fresh [] reference (Object.is bails out are not reference-level), which
cascades through consumers that key on selectedContexts identity.
clearContexts is part of the hook's public API so callers can't know
whether the list is empty — make it safe for them.

Made-with: Cursor
This commit is contained in:
Waleed
2026-04-17 17:38:37 -07:00
committed by GitHub
parent 5e716d74bc
commit 948cdbcc3f
12 changed files with 183 additions and 322 deletions
+6 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
@@ -50,11 +50,14 @@ export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProp
}
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const startTimeRef = useRef(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const resolvedSrc = getAssetUrl(src)
const handleClick = () => {
if (enableLightbox) {
startTimeRef.current = videoRef.current?.currentTime ?? 0
setIsLightboxOpen(true)
}
}
@@ -62,6 +65,7 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp
return (
<>
<video
ref={videoRef}
src={resolvedSrc}
autoPlay
loop
@@ -80,6 +84,7 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp
src={src}
alt={alt}
type='video'
startTime={startTimeRef.current}
/>
)}
</>
-195
View File
@@ -1,195 +0,0 @@
import { memo } from 'react'
const RX = '2.59574'
interface BlockRect {
opacity: number
width: string
height: string
fill: string
x?: string
y?: string
transform?: string
}
const RECTS = {
topRight: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{
opacity: 1,
x: '51.6188',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#2ABBF8',
},
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{
opacity: 1,
x: '123.6484',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
},
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
{
opacity: 1,
x: '260.611',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
},
],
bottomLeft: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{
opacity: 1,
x: '51.6188',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#2ABBF8',
},
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{
opacity: 1,
x: '123.6484',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
},
],
bottomRight: [
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '34.241',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 16.891 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.482',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 16.888)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 33.776)',
},
{
opacity: 1,
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.739 34.272)',
},
{
opacity: 0.6,
width: '16.8626',
height: '34.24',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 68)',
},
{
opacity: 0.4,
width: '16.8626',
height: '16.8626',
fill: '#1A8FCC',
transform: 'matrix(-1 0 0 1 33.787 85)',
},
],
} as const satisfies Record<string, readonly BlockRect[]>
const GLOBAL_OPACITY = 0.55
const BlockGroup = memo(function BlockGroup({
width,
height,
viewBox,
rects,
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
}) {
return (
<svg
width={width}
height={height}
viewBox={viewBox}
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
style={{ opacity: GLOBAL_OPACITY }}
>
{rects.map((r, i) => (
<rect
key={i}
x={r.x}
y={r.y}
width={r.width}
height={r.height}
rx={RX}
fill={r.fill}
transform={r.transform}
opacity={r.opacity}
/>
))}
</svg>
)
})
export function AnimatedBlocks() {
return (
<div
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
aria-hidden='true'
>
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
</div>
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
</div>
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
</div>
</div>
)
}
+9 -8
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface FAQItem {
question: string
@@ -31,9 +32,10 @@ function FAQItemRow({
className='flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left font-[470] text-[0.875rem] text-[rgba(0,0,0,0.8)] transition-colors hover:bg-[rgba(0,0,0,0.02)] dark:text-[rgba(255,255,255,0.85)] dark:hover:bg-[rgba(255,255,255,0.03)]'
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 text-[rgba(0,0,0,0.3)] transition-transform duration-200 dark:text-[rgba(255,255,255,0.3)] ${
isOpen ? 'rotate-90' : ''
}`}
className={cn(
'h-3.5 w-3.5 shrink-0 text-[rgba(0,0,0,0.3)] transition-transform duration-200 dark:text-[rgba(255,255,255,0.3)]',
isOpen && 'rotate-90'
)}
/>
{item.question}
</button>
@@ -81,11 +83,10 @@ export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
{items.map((item, index) => (
<div
key={index}
className={
index !== items.length - 1
? 'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
: ''
}
className={cn(
index !== items.length - 1 &&
'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
)}
>
<FAQItemRow
item={item}
+3 -19
View File
@@ -1,6 +1,5 @@
'use client'
import { useEffect, useState } from 'react'
import { Check } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import {
@@ -25,24 +24,9 @@ export function LanguageDropdown() {
const params = useParams()
const router = useRouter()
const [currentLang, setCurrentLang] = useState(() => {
const langFromParams = params?.lang as string
return langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
})
useEffect(() => {
const langFromParams = params?.lang as string
if (langFromParams && Object.keys(languages).includes(langFromParams)) {
if (langFromParams !== currentLang) {
setCurrentLang(langFromParams)
}
} else {
if (currentLang !== 'en') {
setCurrentLang('en')
}
}
}, [params])
const langFromParams = params?.lang as string
const currentLang =
langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
const handleLanguageChange = (locale: string) => {
if (locale === currentLang) return
+11 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { getAssetUrl } from '@/lib/utils'
interface LightboxProps {
@@ -9,10 +9,12 @@ interface LightboxProps {
src: string
alt: string
type: 'image' | 'video'
startTime?: number
}
export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -40,6 +42,12 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
}
}, [isOpen, onClose])
useLayoutEffect(() => {
if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) {
videoRef.current.currentTime = startTime
}
}, [isOpen, startTime, type])
if (!isOpen) return null
return (
@@ -61,6 +69,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
/>
) : (
<video
ref={videoRef}
src={getAssetUrl(src)}
autoPlay
loop
+11 -3
View File
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { getAssetUrl } from '@/lib/utils'
import { useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface VideoProps {
@@ -27,10 +27,13 @@ export function Video({
width,
height,
}: VideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const startTimeRef = useRef(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const handleVideoClick = () => {
if (enableLightbox) {
startTimeRef.current = videoRef.current?.currentTime ?? 0
setIsLightboxOpen(true)
}
}
@@ -38,13 +41,17 @@ export function Video({
return (
<>
<video
ref={videoRef}
autoPlay={autoPlay}
loop={loop}
muted={muted}
playsInline={playsInline}
width={width}
height={height}
className={`${className} ${enableLightbox ? 'cursor-pointer transition-opacity hover:opacity-95' : ''}`}
className={cn(
className,
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-95'
)}
src={getAssetUrl(src)}
onClick={handleVideoClick}
/>
@@ -56,6 +63,7 @@ export function Video({
src={src}
alt={`Video: ${src}`}
type='video'
startTime={startTimeRef.current}
/>
)}
</>
@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Paperclip } from 'lucide-react'
import {
DropdownMenu,
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus, Sim } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import {
buildWorkflowFolderTree,
type useAvailableResources,
@@ -41,6 +42,7 @@ export const PlusMenuDropdown = React.memo(
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const buttonRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
@@ -55,6 +57,7 @@ export const PlusMenuDropdown = React.memo(
}
setOpen(true)
setSearch('')
setActiveIndex(0)
}, [])
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
@@ -77,25 +80,48 @@ export const PlusMenuDropdown = React.memo(
onResourceSelect(resource)
setOpen(false)
setSearch('')
setActiveIndex(0)
}
// Sync DOM scroll to the keyboard-highlighted filtered row.
useEffect(() => {
if (!filteredItems || filteredItems.length === 0) return
const row = contentRef.current?.querySelector<HTMLElement>(
`[data-filtered-idx="${activeIndex}"]`
)
row?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, filteredItems])
const getVisibleMenuItems = (): HTMLElement[] =>
Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
).filter((el) => el.offsetParent !== null)
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!filteredItems) {
if (e.key === 'ArrowDown') {
e.preventDefault()
getVisibleMenuItems()[0]?.focus()
}
return
}
if (filteredItems.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
} else if (e.key === 'Enter' || e.key === 'Tab') {
setActiveIndex((i) => Math.min(i + 1, filteredItems.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const first = filteredItems?.[0]
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const target = filteredItems[activeIndex] ?? filteredItems[0]
if (target) handleSelect({ type: target.type, id: target.item.id, title: target.item.name })
}
}
const handleContentKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
const items = Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
)
const items = getVisibleMenuItems()
if (items[0] && items[0] === document.activeElement) {
e.preventDefault()
searchRef.current?.focus()
@@ -114,6 +140,7 @@ export const PlusMenuDropdown = React.memo(
if (!isOpen) {
setSearch('')
setAnchorPos(null)
setActiveIndex(0)
onClose()
}
}
@@ -157,100 +184,110 @@ export const PlusMenuDropdown = React.memo(
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }) => {
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
menu FocusScope steal focus from the search input back to the content root. */}
<div hidden={filteredItems !== null}>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{workflowTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
<span>Workflows</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='h-[14px] w-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</div>
{/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
menu Collection, or FocusScope restores focus to the content root. */}
{filteredItems !== null &&
(filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
const isActive = index === activeIndex
return (
<DropdownMenuItem
<button
key={`${type}:${item.id}`}
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
handleSelect({ type, id: item.id, title: item.name })
}}
className={cn(
'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)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
</button>
)
})
) : (
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
No results
</div>
)
) : (
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{workflowTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
<span>Workflows</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='h-[14px] w-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -49,7 +49,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
* Clears all selected contexts
*/
const clearContexts = useCallback(() => {
setSelectedContexts([])
setSelectedContexts((prev) => (prev.length === 0 ? prev : []))
}, [])
/**
@@ -58,7 +58,9 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
*/
useEffect(() => {
if (!message) {
setSelectedContexts([])
// Functional updater bails out when already empty; a fresh `[]` literal would
// emit a new reference and invalidate downstream memos that key on identity.
setSelectedContexts((prev) => (prev.length === 0 ? prev : []))
return
}
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { MoreHorizontal, Search } from 'lucide-react'
import {
@@ -90,7 +90,7 @@ interface WorkspaceHeaderProps {
/**
* Workspace header component that displays workspace name and switcher.
*/
export function WorkspaceHeader({
function WorkspaceHeaderImpl({
activeWorkspace,
workspaceId,
workspaces,
@@ -834,3 +834,5 @@ export function WorkspaceHeader({
</div>
)
}
export const WorkspaceHeader = memo(WorkspaceHeaderImpl)
+4 -1
View File
@@ -56,6 +56,9 @@ export function useFolders(workspaceId?: string, options?: { scope?: FolderQuery
})
}
const selectFolderMap = (folders: WorkflowFolder[]): Record<string, WorkflowFolder> =>
Object.fromEntries(folders.map((folder) => [folder.id, folder]))
export function useFolderMap(workspaceId?: string) {
return useQuery({
queryKey: folderKeys.list(workspaceId),
@@ -63,7 +66,7 @@ export function useFolderMap(workspaceId?: string) {
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
select: (folders) => Object.fromEntries(folders.map((folder) => [folder.id, folder])),
select: selectFolderMap,
})
}
+4 -1
View File
@@ -69,6 +69,9 @@ export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQ
})
}
const selectWorkflowMap = (data: WorkflowMetadata[]): Record<string, WorkflowMetadata> =>
Object.fromEntries(data.map((w) => [w.id, w]))
/**
* Returns workflows as a `Record<string, WorkflowMetadata>` keyed by ID.
* Uses the `select` option so the transformation runs inside React Query
@@ -82,7 +85,7 @@ export function useWorkflowMap(workspaceId?: string, options?: { scope?: Workflo
queryFn: workspaceId ? getWorkflowListQueryOptions(workspaceId, scope).queryFn : skipToken,
placeholderData: keepPreviousData,
staleTime: WORKFLOW_LIST_STALE_TIME,
select: (data) => Object.fromEntries(data.map((w) => [w.id, w])),
select: selectWorkflowMap,
})
}
+3 -1
View File
@@ -55,6 +55,8 @@ async function fetchWorkspaces(
}
}
const selectWorkspaces = (data: WorkspacesResponse): Workspace[] => data.workspaces
/**
* Fetches the current user's workspaces.
* Returns only the workspace array. Use `useWorkspacesWithMetadata` when
@@ -64,7 +66,7 @@ export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope =
return useQuery({
queryKey: workspaceKeys.list(scope),
queryFn: ({ signal }) => fetchWorkspaces(scope, signal),
select: (data) => data.workspaces,
select: selectWorkspaces,
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,