From 270ccb2ef809cd4cc3d1b79eb20909a727f31678 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 23:16:53 -0700 Subject: [PATCH] fix: cancel transient picker focus frames (#3495) --- .../src/components/agent/AgentCombobox.tsx | 18 ++++++++----- .../automations/CreateFromPicker.tsx | 27 ++++++++++++------- .../automations/WorkspaceCombobox.tsx | 27 ++++++++++++------- .../editor/UntitledFileRenameDialog.tsx | 4 ++- .../src/components/repo/RepoCombobox.tsx | 18 ++++++++----- .../components/sidebar/AddRepoCreateStep.tsx | 4 ++- .../components/sidebar/AddRepoSetupStep.tsx | 4 ++- .../sparse/SparseCheckoutPresetSelect.tsx | 4 ++- .../status-bar/ResourceUsageStatusSegment.tsx | 2 ++ 9 files changed, 72 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index cd98347fcd0..c33a9a37023 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -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( diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index b9757bf2439..6a067d0ccd9 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -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) { diff --git a/src/renderer/src/components/automations/WorkspaceCombobox.tsx b/src/renderer/src/components/automations/WorkspaceCombobox.tsx index 6e07f60ffed..2b501789cb0 100644 --- a/src/renderer/src/components/automations/WorkspaceCombobox.tsx +++ b/src/renderer/src/components/automations/WorkspaceCombobox.tsx @@ -28,23 +28,32 @@ export function WorkspaceCombobox({ const focusFrameRef = React.useRef(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 ( diff --git a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx index eff040e2c66..b9fa4caf37e 100644 --- a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx +++ b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx @@ -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) { diff --git a/src/renderer/src/components/repo/RepoCombobox.tsx b/src/renderer/src/components/repo/RepoCombobox.tsx index b2b6a7eebf4..5ba668853df 100644 --- a/src/renderer/src/components/repo/RepoCombobox.tsx +++ b/src/renderer/src/components/repo/RepoCombobox.tsx @@ -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( diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index aefa859bbf6..4d836df283a 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -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. diff --git a/src/renderer/src/components/sidebar/AddRepoSetupStep.tsx b/src/renderer/src/components/sidebar/AddRepoSetupStep.tsx index faf911b3fb7..464586e3672 100644 --- a/src/renderer/src/components/sidebar/AddRepoSetupStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSetupStep.tsx @@ -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. diff --git a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx index f58de9f5013..447f804094f 100644 --- a/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx +++ b/src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx @@ -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. diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 7d4b0bd7b40..a91ec7eb9f2 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -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.