fix(renderer): restore focus after floating tab rename (#31510)

* fix(renderer): restore focus after floating tab rename

* fix: limit one-shot chat composer focus to tab rename return

Other onlyIfFocusUnclaimed callers (e.g. floating terminal) keep main's
replayable composer focus request.

---------

Co-authored-by: MimeLyc <11363886+MimeLyc@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
YochaLyc
2026-10-24 00:24:59 -07:00
committed by GitHub
co-authored by MimeLyc Neil
parent 0399ffa2c6
commit dd69e7dd80
7 changed files with 483 additions and 21 deletions
@@ -8,7 +8,7 @@ import type { NativeChatComposerHandle } from './NativeChatComposer'
const REVEAL_FOCUS_FRAMES = 6
const PENDING_FOCUS_MS = 1000
const revealedComposerReclaims = new Map<string, () => void>()
const revealedComposerReclaims = new Map<string, (onlyIfFocusUnclaimed?: boolean) => void>()
const mountedComposers = new Set<RefObject<NativeChatComposerHandle | null>>()
let pendingFocusRequest: { tabId: string | null; cancel: () => void } | null = null
let focusRequestGeneration = 0
@@ -61,7 +61,15 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI
}
/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */
export function requestNativeChatComposerFocus(tabId: string): void {
export function requestNativeChatComposerFocus(
tabId: string,
options: { onlyIfFocusUnclaimed?: boolean } = {}
): void {
if (options.onlyIfFocusUnclaimed) {
// A rename return is one-shot, not an activation to replay after a hidden composer reveals.
revealedComposerReclaims.get(tabId)?.(true)
return
}
captureNativeChatComposerFocusIntent().request(tabId)
}
@@ -110,7 +118,15 @@ export function useNativeChatComposerRevealFocus({
if (!revealed || !composerReady) {
return
}
const reclaim = (): void => {
const reclaim = (onlyIfFocusUnclaimed = false): void => {
if (onlyIfFocusUnclaimed) {
const root = rootRef.current
const active = root?.ownerDocument.activeElement
if (root && (active === null || active === root.ownerDocument.body)) {
composerRef.current?.focus()
}
return
}
claimedRef.current = false
explicitRef.current = true
const active = rootRef.current?.ownerDocument.activeElement ?? null
@@ -4,7 +4,6 @@ import { X, Minimize2, Pin } from 'lucide-react'
import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration'
import { getAgentLabel } from '@/lib/agent-catalog'
import { basename } from '@/lib/path'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { Input } from '@/components/ui/input'
import { TabHoverCard } from './TabHoverCard'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
@@ -20,6 +19,7 @@ import {
} from './drop-indicator'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
import { useSortableTabRename } from './use-sortable-tab-rename'
import { useTabRenameReturnFocus } from './use-tab-rename-return-focus'
import { SortableTabContextMenu } from './SortableTabContextMenu'
import { useSortableTabContextMenu } from './use-sortable-tab-context-menu'
import { translate } from '@/i18n/i18n'
@@ -159,6 +159,15 @@ export default function SortableTab({
customTitle: tab.customTitle,
onSetCustomTitle
})
const { renameOwnerRef, onRenameKeyDown } = useTabRenameReturnFocus({
commitRename,
cancelRename,
tabId: tab.id,
unifiedTabId,
worktreeId: tab.worktreeId,
groupId,
isNativeChat: nativeChat !== undefined
})
// Why: a live working/needs-input state is newer than a prior-turn unread, so it owns the icon until the turn ends.
const showUnreadActivity =
hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus)
@@ -267,19 +276,7 @@ export default function SortableTab({
)}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={commitRename}
onKeyDown={(event) => {
// Why: an Enter confirming a CJK IME candidate must not commit the rename; wait for a non-composition Enter.
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
commitRename()
} else if (event.key === 'Escape') {
event.preventDefault()
cancelRename()
}
}}
onKeyDown={onRenameKeyDown}
// Why: stop bubbling so clicking inside the input doesn't activate the tab or start a dnd-kit drag.
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => {
@@ -363,6 +360,7 @@ export default function SortableTab({
return (
<>
<div
ref={renameOwnerRef}
{...slotProps}
onContextMenuCapture={(event) => {
// The hover card is portalled: React still routes its events through this slot.
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, type KeyboardEvent } from 'react'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { hasVisibleOverlay } from '@/lib/visible-overlay'
import { useAppStore } from '@/store'
export function useTabRenameReturnFocus({
commitRename,
cancelRename,
tabId,
unifiedTabId,
worktreeId,
groupId,
isNativeChat
}: {
commitRename: () => void
cancelRename: () => void
tabId: string
unifiedTabId: string
worktreeId: string
groupId: string
isNativeChat: boolean
}) {
const renameOwnerRef = useRef<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null)
const generationRef = useRef(0)
useEffect(() => {
return () => {
generationRef.current += 1
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
}
}, [])
const restoreContentFocus = useCallback(() => {
const owner = renameOwnerRef.current
if (!owner) {
return
}
const leafId = useAppStore.getState().terminalLayoutsByTabId[tabId]?.activeLeafId
const generation = ++generationRef.current
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
const canRestoreFocus = (): boolean => {
const state = useAppStore.getState()
const active = document.activeElement
return Boolean(
generationRef.current === generation &&
owner.isConnected &&
owner === renameOwnerRef.current &&
owner.getClientRects().length > 0 &&
getComputedStyle(owner).visibility === 'visible' &&
!owner.closest('[aria-hidden="true"], [inert]') &&
state.activeGroupIdByWorktree[worktreeId] === groupId &&
state.groupsByWorktree[worktreeId]?.some(
(group) => group.id === groupId && group.activeTabId === unifiedTabId
) &&
(isNativeChat || state.terminalLayoutsByTabId[tabId]?.activeLeafId === leafId) &&
(active === null || active === document.body) &&
!document.querySelector('[data-tab-rename-input="true"]') &&
!hasVisibleOverlay({ ignoreContaining: owner, ignoreDismissed: true })
)
}
// Wait for the focused rename input to unmount before checking who owns focus.
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
if (!canRestoreFocus()) {
return
}
if (isNativeChat) {
requestNativeChatComposerFocus(tabId, { onlyIfFocusUnclaimed: true })
} else if (leafId) {
focusTerminalTabSurface(tabId, leafId, {
onlyIfFocusUnclaimed: true,
oneShotChatComposerFocus: true,
shouldFocus: canRestoreFocus
})
}
})
}, [groupId, isNativeChat, tabId, unifiedTabId, worktreeId])
const onRenameKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault()
if (event.key === 'Enter') {
commitRename()
} else {
cancelRename()
}
restoreContentFocus()
}
},
[cancelRename, commitRename, restoreContentFocus]
)
return { renameOwnerRef, onRenameKeyDown }
}
@@ -130,6 +130,31 @@ describe('focusTerminalTabSurface', () => {
expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith('tab-1')
})
it('keeps unclaimed chat focus replayable unless the caller asks for a one-shot rename return', () => {
flushAnimationFrames()
const body = {}
vi.stubGlobal('document', {
body,
activeElement: body,
querySelector: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"]'
? { getAttribute: (name: string) => (name === 'data-terminal-chat-view' ? 'true' : null) }
: null
)
})
focusTerminalTabSurface('tab-1', null, { onlyIfFocusUnclaimed: true })
expect(mocks.requestNativeChatComposerFocus).toHaveBeenLastCalledWith('tab-1')
focusTerminalTabSurface('tab-1', null, {
onlyIfFocusUnclaimed: true,
oneShotChatComposerFocus: true
})
expect(mocks.requestNativeChatComposerFocus).toHaveBeenLastCalledWith('tab-1', {
onlyIfFocusUnclaimed: true
})
})
it('skips the chat leaf helper when a split chat tab has an active terminal leaf', () => {
flushAnimationFrames()
const coveredTextarea = { focus: vi.fn() }
@@ -17,6 +17,9 @@ let pendingFocusFrameIds: number[] = []
type FocusTerminalTabSurfaceOptions = {
onlyIfFocusUnclaimed?: boolean
/** Rename return only: claim an idle chat composer now instead of queueing a replayable reveal request. */
oneShotChatComposerFocus?: boolean
shouldFocus?: () => boolean
onImeRefocusSkipped?: (activeElement: Element | null) => void
refreshImeContext?: boolean
}
@@ -68,7 +71,10 @@ export function focusTerminalTabSurface(
pendingFocusFrameIds = pendingFocusFrameIds.filter((frameId) => frameId !== secondFrameId)
// Why: this can be queued before inline tab rename mounts. If it runs
// afterward, focusing xterm blurs the rename input and commits it closed.
if (document.querySelector('[data-tab-rename-input="true"]')) {
if (
options.shouldFocus?.() === false ||
document.querySelector('[data-tab-rename-input="true"]')
) {
return
}
const escapedTabId = cssAttributeString(tabId)
@@ -76,6 +82,10 @@ export function focusTerminalTabSurface(
if (tabElement?.getAttribute('data-terminal-chat-view') === 'true') {
// The chat over this terminal takes the focus in its composer instead.
if (!options.onlyIfFocusUnclaimed || document.activeElement === document.body) {
if (options.oneShotChatComposerFocus) {
requestNativeChatComposerFocus(tabId, { onlyIfFocusUnclaimed: true })
return
}
requestNativeChatComposerFocus(tabId)
}
return
@@ -0,0 +1,288 @@
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import type { Locator, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
resolveActiveTabId,
splitActiveTerminalPane,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
const FLOATING_WORKTREE_ID = 'global-floating-terminal'
const OPEN_PANEL_SELECTOR = '[data-floating-terminal-panel][aria-hidden="false"]'
const nextTabChord = process.platform === 'darwin' ? 'Meta+Shift+]' : 'Control+Shift+]'
async function openFloatingTerminals(page: Page): Promise<{ first: Locator; second: Locator }> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
const ids = await page.evaluate(async (worktreeId) => {
const store = window.__store!
await store.getState().updateSettings({ floatingTerminalEnabled: true })
const first = store.getState().createTab(worktreeId, undefined, undefined, { activate: false })
const second = store.getState().createTab(worktreeId, undefined, undefined, { activate: false })
store.getState().setTabCustomTitle(first.id, 'Rename target')
store.getState().setTabCustomTitle(second.id, 'Other terminal')
store.setState({ floatingWorkspacePanelOpen: true })
return [first.id, second.id]
}, FLOATING_WORKTREE_ID)
const panel = page.locator(OPEN_PANEL_SELECTOR)
await expect(panel).toBeVisible()
const first = panel.locator(`[data-testid="sortable-tab"][data-tab-id="${ids[0]}"]`)
const second = panel.locator(`[data-testid="sortable-tab"][data-tab-id="${ids[1]}"]`)
await first.click()
await expect(first).toHaveAttribute('data-active', 'true')
await expect(
panel.locator(`[data-terminal-tab-id="${ids[0]}"] .xterm-helper-textarea`)
).toBeFocused()
return { first, second }
}
async function beginRename(
page: Page,
tab: Locator,
entry: 'menu' | 'double-click'
): Promise<Locator> {
if (entry === 'menu') {
await tab.click({ button: 'right' })
await page.getByRole('menuitem', { name: /^Change Title(?:\s|$)/ }).click()
await expect(page.getByRole('menu')).toHaveCount(0)
} else {
await tab.dblclick()
}
const input = tab.locator('[data-tab-rename-input="true"]')
await expect(input).toBeFocused()
// Let activation's deferred focus finish while the rename input still owns focus.
await settleDeferredFocus(page)
return input
}
async function settleDeferredFocus(page: Page): Promise<void> {
await page.evaluate(async () => {
for (let frame = 0; frame < 6; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
})
}
async function expectKeyboardOutput(page: Page, tab: Locator, marker: string): Promise<void> {
const tabId = await tab.getAttribute('data-tab-id')
await expect(
page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea:focus`)
).toHaveCount(1)
await expect
.poll(() =>
page.evaluate((id) => {
const pane = id ? window.__paneManagers?.get(id)?.getActivePane() : null
return Boolean(pane?.container.dataset.ptyId && pane.serializeAddon?.serialize().trim())
}, tabId)
)
.toBe(true)
await settleDeferredFocus(page)
await expect(
page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea:focus`)
).toHaveCount(1)
await page.keyboard.type(`echo ${marker}`)
await page.keyboard.press('Enter')
await expect
.poll(() =>
page.evaluate((id) => {
const buffer = id
? window.__paneManagers?.get(id)?.getActivePane()?.terminal.buffer.active
: null
if (!buffer) {
return []
}
return Array.from({ length: buffer.length }, (_, index) =>
buffer.getLine(index)?.translateToString(true).trim()
)
}, tabId)
)
.toContain(marker)
}
for (const entry of ['menu', 'double-click'] as const) {
for (const exit of ['Enter', 'Escape', 'empty'] as const) {
test(`${entry} rename ${exit} returns floating keyboard input to the terminal`, async ({
orcaPage
}) => {
const { first, second } = await openFloatingTerminals(orcaPage)
await expectKeyboardOutput(orcaPage, first, 'ORCA_RENAME_BASELINE')
await orcaPage.keyboard.press(nextTabChord)
await expect(second).toHaveAttribute('data-active', 'true')
await first.click()
const input = await beginRename(orcaPage, first, entry)
await input.fill('')
if (exit !== 'empty') {
await input.pressSequentially('Renamed terminal', { delay: 60 })
}
await orcaPage.keyboard.press(exit === 'Escape' ? 'Escape' : 'Enter')
await expect(input).toHaveCount(0)
await (exit === 'empty'
? expect(first).not.toHaveAttribute('data-tab-title', 'Rename target')
: expect(first).toHaveAttribute(
'data-tab-title',
exit === 'Escape' ? 'Rename target' : 'Renamed terminal'
))
await expect(
orcaPage.locator(`${OPEN_PANEL_SELECTOR} .xterm-helper-textarea:focus`)
).toHaveCount(1)
await expectKeyboardOutput(orcaPage, first, 'ORCA_RENAME_RECOVERED')
await orcaPage.keyboard.press(nextTabChord)
await expect(second).toHaveAttribute('data-active', 'true')
await expectKeyboardOutput(orcaPage, second, 'ORCA_RENAME_SWITCHED')
})
}
}
test('blur commits without taking keyboard input back from another floating tab', async ({
orcaPage
}) => {
const { first, second } = await openFloatingTerminals(orcaPage)
const input = await beginRename(orcaPage, first, 'double-click')
await input.fill('Blur committed')
await second.click()
await expect(input).toHaveCount(0)
await expect(first).toHaveAttribute('data-tab-title', 'Blur committed')
await expect(second).toHaveAttribute('data-active', 'true')
await expectKeyboardOutput(orcaPage, second, 'ORCA_RENAME_BLUR')
})
test('renaming an inactive floating tab does not activate or focus its terminal', async ({
orcaPage
}) => {
const { first, second } = await openFloatingTerminals(orcaPage)
const input = await beginRename(orcaPage, second, 'menu')
await expect(first).toHaveAttribute('data-active', 'true')
await input.fill('Inactive renamed')
await orcaPage.keyboard.press('Enter')
await expect(second).toHaveAttribute('data-tab-title', 'Inactive renamed')
await settleDeferredFocus(orcaPage)
await expect(first).toHaveAttribute('data-active', 'true')
const secondId = await second.getAttribute('data-tab-id')
await expect(
orcaPage.locator(`[data-terminal-tab-id="${secondId}"] .xterm-helper-textarea:focus`)
).toHaveCount(0)
})
test('blur preserves focus in the worktree search dialog', async ({ orcaPage }) => {
const { first } = await openFloatingTerminals(orcaPage)
const input = await beginRename(orcaPage, first, 'double-click')
await input.fill('Search committed')
await orcaPage
.getByRole('button', { name: 'Search worktrees and browser tabs', exact: true })
.click()
await expect(input).toHaveCount(0)
await expect(first).toHaveAttribute('data-tab-title', 'Search committed')
const search = orcaPage.getByRole('dialog').locator('input').first()
await expect(search).toBeFocused()
await settleDeferredFocus(orcaPage)
await orcaPage.keyboard.type('search owns input')
await expect(search).toHaveValue('search owns input')
})
for (const change of ['hide', 'close'] as const) {
test(`a ${change} at rename completion cancels the deferred floating focus return`, async ({
orcaPage
}) => {
const { first } = await openFloatingTerminals(orcaPage)
const tabId = await first.getAttribute('data-tab-id')
const input = await beginRename(orcaPage, first, 'menu')
await input.fill('Exiting tab')
await orcaPage.evaluate(
({ tabId, change }) => {
// Run after React handles Enter, but before any queued animation frame.
document.addEventListener(
'keydown',
() => {
const store = window.__store!
if (change === 'hide') {
store.setState({ floatingWorkspacePanelOpen: false })
} else if (tabId) {
store.getState().closeTab(tabId)
}
},
{ once: true }
)
},
{ tabId, change }
)
await orcaPage.keyboard.press('Enter')
await expect(input).toBeHidden()
await settleDeferredFocus(orcaPage)
await expect(
orcaPage.locator(`[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea:focus`)
).toHaveCount(0)
})
}
for (const workspace of ['git', 'folder'] as const) {
test(`keyboard rename in a ${workspace} workspace returns input to the selected split leaf`, async ({
orcaPage,
electronApp
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
if (workspace === 'folder') {
const folderPath = path.join(
await electronApp.evaluate(({ app }) => app.getPath('home')),
'rename-folder'
)
mkdirSync(folderPath)
await orcaPage.evaluate(async (folderPath) => {
const state = window.__store!.getState()
await state.updateSettingsOrThrow({ defaultTuiAgent: 'blank' })
const group = await window.api.projectGroups.create({
name: 'Rename regression',
parentPath: folderPath,
createdFrom: 'folder-scan'
})
await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true })
await state.createFolderWorkspace(
{ projectGroupId: group.id, name: 'Rename folder', folderPath },
{ runtimeEnvironmentId: null }
)
state.openModal('worktree-palette')
}, folderPath)
const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' })
await palette.getByRole('combobox').fill('Rename folder')
await palette.getByRole('combobox').press('Enter')
await expect(palette).toHaveCount(0)
}
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
await splitActiveTerminalPane(orcaPage, 'horizontal')
await waitForPaneIdentitySnapshot(orcaPage, 2)
const tabId = await resolveActiveTabId(orcaPage)
const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
const panes = orcaPage.locator(`[data-terminal-tab-id="${tabId}"] .pane[data-leaf-id]`)
await panes.first().locator('.xterm-screen').click()
await panes.last().locator('.xterm-screen').click()
const leafId = await panes.last().getAttribute('data-leaf-id')
expect(leafId).toBeTruthy()
await expect
.poll(() =>
orcaPage.evaluate(
(id) => (id ? window.__store?.getState().terminalLayoutsByTabId[id]?.activeLeafId : null),
tabId
)
)
.toBe(leafId)
const input = await beginRename(orcaPage, tab, 'menu')
await input.fill('Split renamed')
await orcaPage.keyboard.press('Enter')
await expect(input).toHaveCount(0)
await expect(
orcaPage.locator(`[data-leaf-id="${leafId}"] .xterm-helper-textarea`)
).toBeFocused()
await expectKeyboardOutput(orcaPage, tab, 'ORCA_RENAME_SPLIT')
await orcaPage.screenshot({ path: testInfo.outputPath('split-rename-focus.png') })
expect(
await electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
)
).toBe(true)
})
}
+23 -3
View File
@@ -1,4 +1,4 @@
// Clicking Send or Stop leaves focus in the composer, as Enter does, after the button disables.
// Completed tab and composer actions return keyboard input to the chat draft.
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect } from './helpers/orca-app'
@@ -30,11 +30,13 @@ async function openChat(page: Page, claudeCommand: string) {
{ worktreeId, sessionId }
)
await expect.poll(readTabId, { timeout: 30_000 }).toBeTruthy()
await page.locator(`[data-testid="sortable-tab"][data-tab-id="${await readTabId()}"]`).click()
const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${await readTabId()}"]`)
await tab.click()
const chat = page.locator('[data-native-chat-root="true"]')
const composer = chat.getByRole('textbox')
await composer.click()
return {
tab,
composer,
send: chat.getByRole('button', { name: 'Send', exact: true }),
stop: chat.getByRole('button', { name: 'Stop the agent' }),
@@ -42,9 +44,27 @@ async function openChat(page: Page, claudeCommand: string) {
}
}
test.describe('a click on the native chat composer button keeps focus in the composer', () => {
test.describe('native chat composer focus', () => {
test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only')
test('keyboard tab rename returns input to the native chat composer', async ({
orcaPage: page,
claudeStub
}) => {
const { tab, composer } = await openChat(page, claudeStub.command)
await tab.dblclick()
const rename = tab.locator('[data-tab-rename-input="true"]')
await expect(rename).toBeFocused()
await rename.fill('')
await rename.pressSequentially('Renamed chat', { delay: 60 })
await page.keyboard.press('Enter')
await expect(rename).toHaveCount(0)
await expect(tab).toHaveAttribute('data-tab-title', 'Renamed chat')
await expect.poll(() => focusIsIn(composer)).toBe(true)
await page.keyboard.type('Draft after rename')
await expect(composer).toContainText('Draft after rename')
})
test('when Send disables at the end of the turn', async ({ orcaPage: page, claudeStub }) => {
const { composer, send, stop, reply } = await openChat(page, claudeStub.command)
await page.keyboard.type('hello')