From 34c90eaab446e3d822e87f0d8d5b896af1c56028 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 18:11:55 -0700 Subject: [PATCH] Focus terminal tab rename input from ref (#3314) * Focus terminal tab rename input from ref * Preserve tab rename focus after menu selection Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../src/components/tab-bar/SortableTab.tsx | 29 ++++++++++--------- tests/e2e/tab-rename.spec.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 29d5fe6b8..6fc96dcc5 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -115,7 +115,7 @@ export default function SortableTab({ // (e.g. showing the bell without the wash, or vice versa). const showActivityAffordance = hasUnreadActivity && !isEditing const [renameValue, setRenameValue] = useState('') - const renameInputRef = useRef(null) + const renameFocusFrameRef = useRef(null) // Why: React's synthetic onBlur fires during the Input's unmount when isEditing flips // to false. Without this guard, pressing Escape (or committing via Enter) would cause // the blur handler to run commitRename a second time and overwrite the title with the @@ -148,21 +148,22 @@ export default function SortableTab({ setIsEditing(false) }, []) - // Why: rAF defers focus()+select() until after the Input mounts so the text - // is pre-selected (overwriting the old title is the common case). Deps are - // intentionally just [isEditing] — we do NOT re-run when tab.title or - // tab.customTitle change mid-edit, so external title updates cannot - // re-focus/re-select and disrupt the user's typing. - useEffect(() => { - if (!isEditing) { + const setRenameInputElement = useCallback((input: HTMLInputElement | null) => { + if (renameFocusFrameRef.current !== null) { + cancelAnimationFrame(renameFocusFrameRef.current) + renameFocusFrameRef.current = null + } + if (!input) { return } - const frame = requestAnimationFrame(() => { - renameInputRef.current?.focus() - renameInputRef.current?.select() + // Why: defer past Radix menu teardown/focus restore while still keying off + // input mount only; terminal title updates must not re-select in-progress text. + renameFocusFrameRef.current = requestAnimationFrame(() => { + renameFocusFrameRef.current = null + input.focus() + input.select() }) - return () => cancelAnimationFrame(frame) - }, [isEditing]) + }, []) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -297,7 +298,7 @@ export default function SortableTab({ )} {isEditing ? ( { await expect(tabLocatorByTitle(orcaPage, 'My Custom Title')).toBeVisible() }) + test('context-menu Change Title opens a focused select-all rename input', async ({ + orcaPage + }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const originalTitle = await getActiveTabTitle(orcaPage, worktreeId) + expect(originalTitle.length).toBeGreaterThan(0) + + await tabLocatorByTitle(orcaPage, originalTitle).click({ button: 'right' }) + await orcaPage.getByRole('menuitem', { name: 'Change Title', exact: true }).click() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${originalTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + await expect(renameInput).toBeFocused() + + // Why: plain keyboard typing exercises the real focused selection. If the + // context menu steals focus back or the title is not selected, this will not + // replace the original text with the intended custom title. + await orcaPage.keyboard.type('Context Menu Title') + await renameInput.press('Enter') + + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe('Context Menu Title') + await expect(tabLocatorByTitle(orcaPage, 'Context Menu Title')).toBeVisible() + }) + test('Escape during inline rename discards the edit', async ({ orcaPage }) => { const worktreeId = (await getActiveWorktreeId(orcaPage))! const originalTitle = await getActiveTabTitle(orcaPage, worktreeId)