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 <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-30 18:11:55 -07:00 committed by GitHub
parent 35bcf4af49
commit 34c90eaab4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 44 additions and 14 deletions

View File

@ -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<HTMLInputElement>(null)
const renameFocusFrameRef = useRef<number | null>(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 ? (
<Input
ref={renameInputRef}
ref={setRenameInputElement}
data-tab-rename-input="true"
value={renameValue}
aria-label={`Rename tab ${tabTitle}`}

View File

@ -118,6 +118,35 @@ test.describe('Tab Rename (Inline)', () => {
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)