fix: cancel transient picker focus frames (#3495)

This commit is contained in:
Neil
2026-05-29 23:16:53 -07:00
committed by GitHub
parent 519a120828
commit 270ccb2ef8
9 changed files with 72 additions and 36 deletions
@@ -154,10 +154,17 @@ export default function AgentCombobox({
return 'blank terminal'.includes(q) || 'terminal'.startsWith(q)
}, [query])
const focusSearchInput = useCallback(() => {
const cancelFocusFrame = useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
React.useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const focusSearchInput = useCallback(() => {
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
const searchInput = inputRef.current
@@ -171,7 +178,7 @@ export default function AgentCombobox({
const end = searchInput.value.length
searchInput.setSelectionRange(end, end)
})
}, [])
}, [cancelFocusFrame])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
@@ -180,13 +187,10 @@ export default function AgentCombobox({
setCommandValue(value ?? BLANK_VALUE)
return
}
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
cancelFocusFrame()
setQuery('')
},
[value]
[cancelFocusFrame, value]
)
const handleSelect = useCallback(
@@ -70,23 +70,32 @@ export function CreateFromPicker({
return Array.from(options).sort((left, right) => left.localeCompare(right))
}, [effectiveDefault, searchResults, worktrees])
const focusSearchInput = React.useCallback(() => {
const cancelFocusFrame = React.useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
React.useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const focusSearchInput = React.useCallback(() => {
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
inputRef.current?.focus()
})
}, [])
}, [cancelFocusFrame])
const handleOpenChange = React.useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen && focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
cancelFocusFrame()
}
},
[cancelFocusFrame]
)
React.useEffect(() => {
if (!repoId) {
@@ -28,23 +28,32 @@ export function WorkspaceCombobox({
const focusFrameRef = React.useRef<number | null>(null)
const selected = worktrees.find((worktree) => worktree.id === value) ?? null
const focusSearchInput = React.useCallback(() => {
const cancelFocusFrame = React.useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
React.useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const focusSearchInput = React.useCallback(() => {
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
inputRef.current?.focus()
})
}, [])
}, [cancelFocusFrame])
const handleOpenChange = React.useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen && focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
cancelFocusFrame()
}
},
[cancelFocusFrame]
)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -48,6 +48,8 @@ export function UntitledFileRenameDialog({
}
}, [])
useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const setNameInputNode = useCallback(
(node: HTMLInputElement | null): void => {
if (!node) {
@@ -56,10 +56,17 @@ export default function RepoCombobox({
)
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
const focusSearchInput = useCallback(() => {
const cancelFocusFrame = useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
React.useEffect(() => cancelFocusFrame, [cancelFocusFrame])
const focusSearchInput = useCallback(() => {
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
const repoSearchInput = inputRef.current
@@ -73,7 +80,7 @@ export default function RepoCombobox({
const end = repoSearchInput.value.length
repoSearchInput.setSelectionRange(end, end)
})
}, [])
}, [cancelFocusFrame])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
@@ -82,16 +89,13 @@ export default function RepoCombobox({
setCommandValue(value)
return
}
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
cancelFocusFrame()
// Why: the create-worktree dialog delays its own field reset until after
// close animation, so the repo picker must clear its local filter here or a
// stale query can reopen to an apparently missing repo list.
setQuery('')
},
[value]
[cancelFocusFrame, value]
)
const handleSelect = useCallback(
@@ -5,7 +5,7 @@
* oxlint limit, following the same pattern as useRemoteRepo.
*/
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Folder, GitBranch, Home, Pencil } from 'lucide-react'
import { useAppStore } from '@/store'
@@ -301,6 +301,8 @@ export function CreateStep({
radioFocusFrameRef.current = null
}, [])
useEffect(() => cancelRadioFocusFrame, [cancelRadioFocusFrame])
const setRadioGroupNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued arrow-key focus is only valid while this radiogroup is mounted.
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { GitBranch, GitBranchPlus, Settings } from 'lucide-react'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -158,6 +158,8 @@ export function ProjectAddedContent({
radioFocusFrameRef.current = null
}, [])
useEffect(() => cancelRadioFocusFrame, [cancelRadioFocusFrame])
const setRadioGroupNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued arrow-key focus is only valid while this radiogroup is mounted.
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, LoaderCircle, Pencil, Plus, RefreshCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -84,6 +84,8 @@ export default function SparseCheckoutPresetSelect({
nameInputFocusFrameRef.current = null
}, [])
useEffect(() => cancelNameInputFocusFrame, [cancelNameInputFocusFrame])
const setNameInputNode = useCallback(
(node: HTMLInputElement | null): void => {
// Why: the queued draft focus is only valid while this input is mounted.
@@ -704,6 +704,8 @@ export function ResourceUsageStatusSegment({
popoverBodyFocusFrameRef.current = null
}, [])
useEffect(() => cancelPopoverBodyFocusFrame, [cancelPopoverBodyFocusFrame])
const setPopoverBodyNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued post-kill focus is only valid while the popover body exists.