mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user