mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user