diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 0eb328a4d69..f803ac360d7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -8,7 +8,7 @@ import type { NativeChatComposerHandle } from './NativeChatComposer' const REVEAL_FOCUS_FRAMES = 6 const PENDING_FOCUS_MS = 1000 -const revealedComposerReclaims = new Map void>() +const revealedComposerReclaims = new Map void>() const mountedComposers = new Set>() 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 diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 94f5684ac01..ee963c318b5 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -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 ( <>
{ // The hover card is portalled: React still routes its events through this slot. diff --git a/src/renderer/src/components/tab-bar/use-tab-rename-return-focus.ts b/src/renderer/src/components/tab-bar/use-tab-rename-return-focus.ts new file mode 100644 index 00000000000..3739132ac0a --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-rename-return-focus.ts @@ -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(null) + const frameRef = useRef(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) => { + 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 } +} diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index a019e583ac5..e75f71d2e81 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -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() } diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index f0230a5e6d9..e79b400c00d 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -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 diff --git a/tests/e2e/floating-terminal-tab-rename-focus.spec.ts b/tests/e2e/floating-terminal-tab-rename-focus.spec.ts new file mode 100644 index 00000000000..2c7bd962e8a --- /dev/null +++ b/tests/e2e/floating-terminal-tab-rename-focus.spec.ts @@ -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 { + 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 { + await page.evaluate(async () => { + for (let frame = 0; frame < 6; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + }) +} + +async function expectKeyboardOutput(page: Page, tab: Locator, marker: string): Promise { + 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) + }) +} diff --git a/tests/e2e/native-chat-send-focus.spec.ts b/tests/e2e/native-chat-send-focus.spec.ts index 5dd70810063..503730b41cd 100644 --- a/tests/e2e/native-chat-send-focus.spec.ts +++ b/tests/e2e/native-chat-send-focus.spec.ts @@ -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')