Skip to content

Commit 948cdbc

Browse files
authored
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
1 parent 5e716d7 commit 948cdbc

File tree

12 files changed

+183
-322
lines changed

12 files changed

+183
-322
lines changed

apps/docs/components/ui/action-media.tsx

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

3-
import { useState } from 'react'
3+
import { useRef, useState } from 'react'
44
import { cn, getAssetUrl } from '@/lib/utils'
55
import { Lightbox } from './lightbox'
66

@@ -50,18 +50,22 @@ export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProp
5050
}
5151

5252
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
53+
const videoRef = useRef<HTMLVideoElement>(null)
54+
const startTimeRef = useRef(0)
5355
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
5456
const resolvedSrc = getAssetUrl(src)
5557

5658
const handleClick = () => {
5759
if (enableLightbox) {
60+
startTimeRef.current = videoRef.current?.currentTime ?? 0
5861
setIsLightboxOpen(true)
5962
}
6063
}
6164

6265
return (
6366
<>
6467
<video
68+
ref={videoRef}
6569
src={resolvedSrc}
6670
autoPlay
6771
loop
@@ -80,6 +84,7 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp
8084
src={src}
8185
alt={alt}
8286
type='video'
87+
startTime={startTimeRef.current}
8388
/>
8489
)}
8590
</>

apps/docs/components/ui/animated-blocks.tsx

Lines changed: 0 additions & 195 deletions
This file was deleted.

apps/docs/components/ui/faq.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import { ChevronRight } from 'lucide-react'
5+
import { cn } from '@/lib/utils'
56

67
interface FAQItem {
78
question: string
@@ -31,9 +32,10 @@ function FAQItemRow({
3132
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)]'
3233
>
3334
<ChevronRight
34-
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)] ${
35-
isOpen ? 'rotate-90' : ''
36-
}`}
35+
className={cn(
36+
'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)]',
37+
isOpen && 'rotate-90'
38+
)}
3739
/>
3840
{item.question}
3941
</button>
@@ -81,11 +83,10 @@ export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
8183
{items.map((item, index) => (
8284
<div
8385
key={index}
84-
className={
85-
index !== items.length - 1
86-
? 'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
87-
: ''
88-
}
86+
className={cn(
87+
index !== items.length - 1 &&
88+
'border-[rgba(0,0,0,0.08)] border-b dark:border-[rgba(255,255,255,0.08)]'
89+
)}
8990
>
9091
<FAQItemRow
9192
item={item}

apps/docs/components/ui/language-dropdown.tsx

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

3-
import { useEffect, useState } from 'react'
43
import { Check } from 'lucide-react'
54
import { useParams, usePathname, useRouter } from 'next/navigation'
65
import {
@@ -25,24 +24,9 @@ export function LanguageDropdown() {
2524
const params = useParams()
2625
const router = useRouter()
2726

28-
const [currentLang, setCurrentLang] = useState(() => {
29-
const langFromParams = params?.lang as string
30-
return langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
31-
})
32-
33-
useEffect(() => {
34-
const langFromParams = params?.lang as string
35-
36-
if (langFromParams && Object.keys(languages).includes(langFromParams)) {
37-
if (langFromParams !== currentLang) {
38-
setCurrentLang(langFromParams)
39-
}
40-
} else {
41-
if (currentLang !== 'en') {
42-
setCurrentLang('en')
43-
}
44-
}
45-
}, [params])
27+
const langFromParams = params?.lang as string
28+
const currentLang =
29+
langFromParams && Object.keys(languages).includes(langFromParams) ? langFromParams : 'en'
4630

4731
const handleLanguageChange = (locale: string) => {
4832
if (locale === currentLang) return

apps/docs/components/ui/lightbox.tsx

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

3-
import { useEffect, useRef } from 'react'
3+
import { useEffect, useLayoutEffect, useRef } from 'react'
44
import { getAssetUrl } from '@/lib/utils'
55

66
interface LightboxProps {
@@ -9,10 +9,12 @@ interface LightboxProps {
99
src: string
1010
alt: string
1111
type: 'image' | 'video'
12+
startTime?: number
1213
}
1314

14-
export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
15+
export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) {
1516
const overlayRef = useRef<HTMLDivElement>(null)
17+
const videoRef = useRef<HTMLVideoElement>(null)
1618

1719
useEffect(() => {
1820
const handleKeyDown = (event: KeyboardEvent) => {
@@ -40,6 +42,12 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
4042
}
4143
}, [isOpen, onClose])
4244

45+
useLayoutEffect(() => {
46+
if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) {
47+
videoRef.current.currentTime = startTime
48+
}
49+
}, [isOpen, startTime, type])
50+
4351
if (!isOpen) return null
4452

4553
return (
@@ -61,6 +69,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type }: LightboxProps) {
6169
/>
6270
) : (
6371
<video
72+
ref={videoRef}
6473
src={getAssetUrl(src)}
6574
autoPlay
6675
loop

0 commit comments

Comments
 (0)