diff --git a/apps/desktop/e2e/pane-tab-close.spec.ts b/apps/desktop/e2e/pane-tab-close.spec.ts new file mode 100644 index 0000000000000..cebba7fe9d7ea --- /dev/null +++ b/apps/desktop/e2e/pane-tab-close.spec.ts @@ -0,0 +1,222 @@ +/** + * E2E coverage for directly closing stacked session tabs. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +let fixture: MockBackendFixture | null = null + +const REPLY = 'Hello from the mock inference server! The full boot chain is working.' + +async function sendMessage(page: MockBackendFixture['page'], text: string): Promise { + const activeTranscript = page.locator('[data-slot="aui_thread-viewport"]:visible').last() + + // A new draft briefly coexists with the previous session while the renderer + // switches context. Wait for the new empty transcript instead of sleeping. + await expect(activeTranscript).not.toContainText(REPLY, { timeout: 10_000 }) + + const composer = page.locator('[contenteditable="true"]:visible').last() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await expect.poll(() => composer.textContent(), { timeout: 10_000 }).toBe('') + await composer.click() + await expect(composer).toBeFocused() + await composer.type(text, { delay: 20 }) + await page.keyboard.press('Enter') + + await expect(activeTranscript).toContainText(text, { timeout: 15_000 }) + await expect(activeTranscript).toContainText(REPLY, { timeout: 60_000 }) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('reveals a close control and closes an identified inactive tab without changing selection', async ({ + playwright: _playwright +}, testInfo) => { + const page = fixture!.page + + await sendMessage(page, 'first close-button session') + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await sendMessage(page, 'second close-button session') + + const sessionRows = page.locator('[data-slot="sidebar"] button:has([data-reorder-handle])') + await expect(sessionRows).toHaveCount(2) + + // Dispatch the ctrl-modified click directly so macOS does not translate the + // gesture into a native context click before React receives it. + await sessionRows.last().dispatchEvent('click', { ctrlKey: true }) + + const closeButtons = page.locator('[data-tree-tab] > button[aria-label="Close tab"]') + await expect.poll(() => closeButtons.count()).toBeGreaterThan(1) + + const tabList = closeButtons.first().locator('xpath=ancestor::*[@role="tablist"][1]') + const tabItems = tabList.locator('[data-tree-tab]') + const initialTabCount = await tabItems.count() + const selectedTab = tabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + + const inactiveTab = tabList + .locator('[data-tree-tab]:has(> button[aria-label="Close tab"]) > [role="tab"][aria-selected="false"]') + .first() + + await expect(selectedTab).toHaveCount(1) + await expect(inactiveTab).toHaveCount(1) + await expect(selectedTab).toHaveAttribute('tabindex', '0') + await expect(inactiveTab).toHaveAttribute('tabindex', '-1') + + const selectedTabItem = selectedTab.locator('xpath=..') + const inactiveTabItem = inactiveTab.locator('xpath=..') + const selectedTabId = await selectedTabItem.getAttribute('data-tree-tab') + const inactiveTabId = await inactiveTabItem.getAttribute('data-tree-tab') + + if (!selectedTabId || !inactiveTabId) { + throw new Error('Expected stacked pane tabs to expose stable data-tree-tab identities') + } + + expect(inactiveTabId).not.toBe(selectedTabId) + + const activeTranscript = page.locator('[data-slot="aui_thread-viewport"]:visible').last() + await expect(activeTranscript).toContainText('first close-button session') + + const closeButton = inactiveTabItem.locator('button[aria-label="Close tab"]') + const closeIcon = closeButton.locator('[data-slot="pane-tab-close-icon"]') + await expect(closeIcon).toHaveCSS('opacity', '0') + + // The close glyph is a sibling of the tab control. Its right-click must + // still reach the session trigger that wraps the whole visual tab, rather + // than falling into the strip menu's dead zone. + await closeButton.click({ button: 'right' }) + const sessionMenu = page.getByRole('menu', { name: 'Session actions' }) + await expect(sessionMenu).toBeVisible() + await expect(sessionMenu.getByRole('menuitem', { name: 'Close', exact: true })).toBeVisible() + await page.keyboard.press('Escape') + await expect(sessionMenu).toBeHidden() + + await inactiveTabItem.hover() + await expect(closeIcon).toHaveCSS('opacity', '1') + await page.screenshot({ path: testInfo.outputPath('tab-close-hover.png') }) + + // Preserve the direct pointer path: a pointer close removes only the + // identified inactive tab and recovers focus to the surviving selection. + const closeBox = await closeButton.boundingBox() + + if (!closeBox) { + throw new Error('Expected the inactive tab close control to be visible') + } + + await page.mouse.move(closeBox.x + closeBox.width / 2, closeBox.y + closeBox.height / 2) + await page.mouse.down() + await expect(closeButton).toBeFocused() + await page.mouse.up() + await expect + .poll(() => tabItems.evaluateAll(items => items.map(item => item.getAttribute('data-tree-tab')))) + .not.toContain(inactiveTabId) + await expect(tabItems).toHaveCount(initialTabCount - 1) + + const pointerRemainingSelectedTab = tabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + await expect(pointerRemainingSelectedTab).toHaveCount(1) + await expect(pointerRemainingSelectedTab.locator('xpath=..')).toHaveAttribute('data-tree-tab', selectedTabId) + await expect(pointerRemainingSelectedTab).toBeFocused() + await expect(activeTranscript).toContainText('first close-button session') + + // Recreate a stacked inactive session so this test also follows the real + // keyboard route (native Tab into the close button, then Enter). + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await sendMessage(page, 'third close-button session') + await sessionRows.last().dispatchEvent('click', { ctrlKey: true }) + + const keyboardCloseButtons = page.locator('[data-tree-tab] > button[aria-label="Close tab"]') + await expect.poll(() => keyboardCloseButtons.count()).toBeGreaterThan(1) + + const keyboardTabList = keyboardCloseButtons.first().locator('xpath=ancestor::*[@role="tablist"][1]') + const keyboardTabItems = keyboardTabList.locator('[data-tree-tab]') + const keyboardInitialTabCount = await keyboardTabItems.count() + const keyboardSelectedTab = keyboardTabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + const keyboardInactiveTab = keyboardTabList + .locator('[data-tree-tab]:has(> button[aria-label="Close tab"]) > [role="tab"][aria-selected="false"]') + .first() + + await expect(keyboardSelectedTab).toHaveCount(1) + await expect(keyboardInactiveTab).toHaveCount(1) + + const keyboardSelectedTabItem = keyboardSelectedTab.locator('xpath=..') + const keyboardInactiveTabItem = keyboardInactiveTab.locator('xpath=..') + const keyboardSelectedTabId = await keyboardSelectedTabItem.getAttribute('data-tree-tab') + const keyboardInactiveTabId = await keyboardInactiveTabItem.getAttribute('data-tree-tab') + + if (!keyboardSelectedTabId || !keyboardInactiveTabId) { + throw new Error('Expected a recreated stacked tab pair for keyboard close coverage') + } + + const keyboardCloseButton = keyboardInactiveTabItem.locator('button[aria-label="Close tab"]') + const keyboardCloseIcon = keyboardCloseButton.locator('[data-slot="pane-tab-close-icon"]') + await keyboardInactiveTab.focus() + await page.keyboard.press('Tab') + await expect(keyboardCloseButton).toBeFocused() + await expect(keyboardCloseIcon).toHaveCSS('opacity', '1') + await page.keyboard.press('Enter') + await expect + .poll(() => keyboardTabItems.evaluateAll(items => items.map(item => item.getAttribute('data-tree-tab')))) + .not.toContain(keyboardInactiveTabId) + await expect(keyboardTabItems).toHaveCount(keyboardInitialTabCount - 1) + + const remainingSelectedTab = keyboardTabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + await expect(remainingSelectedTab).toHaveCount(1) + await expect(remainingSelectedTab.locator('xpath=..')).toHaveAttribute('data-tree-tab', keyboardSelectedTabId) + await expect(remainingSelectedTab).toBeFocused() + await expect(activeTranscript).toContainText('first close-button session') + + const remainingTabs = keyboardTabList.locator('[data-tree-tab] > [role="tab"]') + await remainingSelectedTab.focus() + await page.keyboard.press('Home') + await expect(remainingTabs.first()).toBeFocused() + await page.keyboard.press('End') + await expect(remainingTabs.last()).toBeFocused() + + // The platform close shortcut is a separate route from the tab's close + // control. Keep a sibling in the stack, focus the selected tab, then prove + // the root-level recovery moves focus to the surviving tab instead of body. + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await sendMessage(page, 'fourth close-button session') + await sessionRows.last().dispatchEvent('click', { ctrlKey: true }) + + const commandTabItems = keyboardTabList.locator('[data-tree-tab]') + const commandCloseableTab = keyboardTabList.locator('[data-tree-tab^="session-tile:"] > [role="tab"]') + + await expect(commandCloseableTab).toHaveCount(1) + await commandCloseableTab.click() + await expect(commandCloseableTab).toHaveAttribute('aria-selected', 'true') + + const commandSelectedTab = keyboardTabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + const commandSelectedItem = commandSelectedTab.locator('xpath=..') + const commandInactiveTab = keyboardTabList.locator('[data-tree-tab] > [role="tab"][aria-selected="false"]') + const commandSelectedId = await commandSelectedItem.getAttribute('data-tree-tab') + + if (!commandSelectedId) { + throw new Error('Expected ⌘W target tab to expose a stable data-tree-tab identity') + } + + expect(commandSelectedId).toMatch(/^session-tile:/) + await expect(commandSelectedTab).toHaveCount(1) + await expect(commandInactiveTab).toHaveCount(1) + await commandSelectedTab.focus() + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + + await expect + .poll(() => commandTabItems.evaluateAll(items => items.map(item => item.getAttribute('data-tree-tab')))) + .not.toContain(commandSelectedId) + await expect(commandTabItems).toHaveCount(1) + + const commandRemainingTab = keyboardTabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + await expect(commandRemainingTab).toHaveCount(1) + await expect(commandRemainingTab).toBeFocused() +}) diff --git a/apps/desktop/e2e/right-pane.spec.ts b/apps/desktop/e2e/right-pane.spec.ts index 5447955f45274..ab187e2eb94bd 100644 --- a/apps/desktop/e2e/right-pane.spec.ts +++ b/apps/desktop/e2e/right-pane.spec.ts @@ -1,10 +1,9 @@ -import { test, expect } from './test' - import { type MockBackendFixture, setupMockBackend, waitForAppReady, } from './fixtures' +import { expect, test } from './test' let fixture: MockBackendFixture | null = null @@ -136,3 +135,195 @@ test('persistent terminal overlay follows the pane after split dragging', async expect(result.moved).toBeGreaterThan(10) expect(result.drift).toBeLessThanOrEqual(1) }) + +test('a primary click restores focus from a minimized terminal tree rail', async () => { + const page = fixture!.page + const terminalSlot = page.locator('[data-terminal-slot]') + + if (!(await terminalSlot.isVisible())) { + await page.keyboard.press('Control+`') + await terminalSlot.waitFor({ state: 'visible', timeout: 30_000 }) + } + + // Quad places the terminal in a row split, so minimizing it produces the + // vertical rail whose focused primary-click handoff is under test. + await page.getByRole('button', { name: 'Layout editor' }).click() + await page.getByRole('button', { name: 'Quad', exact: true }).click() + await page.getByRole('button', { name: 'Done', exact: true }).click() + + const terminalTreeGroup = page.locator('[data-tree-group]').filter({ + has: page.locator('[data-tree-tab="terminal"]') + }) + + await expect(terminalTreeGroup).toHaveCount(1, { timeout: 30_000 }) + await terminalTreeGroup.getByRole('button', { name: 'Minimize' }).click() + + const minimizedTerminalTab = terminalTreeGroup.locator('[data-tree-tab="terminal"] > [role="tab"]') + await expect(minimizedTerminalTab).toBeVisible({ timeout: 30_000 }) + await minimizedTerminalTab.focus() + await expect(minimizedTerminalTab).toBeFocused() + await minimizedTerminalTab.click() + + const restoredTerminalTab = terminalTreeGroup.locator('[data-tree-tab="terminal"] > [role="tab"]') + await expect(terminalTreeGroup.getByRole('button', { name: 'Minimize' })).toBeVisible({ timeout: 30_000 }) + await expect(restoredTerminalTab).toBeFocused({ timeout: 30_000 }) +}) + +test('terminal rail uses roving tabs with matching panels and its global close gesture', async () => { + const page = fixture!.page + const terminalSlot = page.locator('[data-terminal-slot]') + + if (!(await terminalSlot.isVisible())) { + await page.keyboard.press('Control+`') + await terminalSlot.waitFor({ state: 'visible', timeout: 30_000 }) + } + + const railTabs = page.locator('[data-terminal-rail-tab][role="tab"]') + await expect(railTabs).toHaveCount(1, { timeout: 30_000 }) + await page.getByRole('button', { name: 'New terminal' }).click() + await expect(railTabs).toHaveCount(2, { timeout: 30_000 }) + + const railTablist = railTabs.first().locator('xpath=ancestor::*[@role="tablist"][1]') + const initiallySelectedTab = railTablist.locator('[role="tab"][aria-selected="true"]') + await expect(railTablist).toHaveAttribute('aria-orientation', 'vertical') + await expect(initiallySelectedTab).toHaveCount(1) + await expect(initiallySelectedTab).toHaveAttribute('tabindex', '0') + await expect(railTablist.locator('[role="tab"][aria-selected="false"]').first()).toHaveAttribute('tabindex', '-1') + + await railTabs.first().focus() + await page.keyboard.press('End') + + const selectedTab = railTablist.locator('[role="tab"][aria-selected="true"]') + const panelId = await selectedTab.getAttribute('aria-controls') + const selectedTabId = await selectedTab.getAttribute('id') + + if (!panelId || !selectedTabId) { + throw new Error('Expected a selected terminal tab with linked panel identifiers') + } + + await expect(selectedTab).toBeFocused() + await expect(page.locator(`[role="tabpanel"][id="${panelId}"]`)).toHaveAttribute('aria-labelledby', selectedTabId) + await expect(page.locator(`[role="tabpanel"][id="${panelId}"]`)).toHaveAttribute('aria-hidden', 'false') + + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(railTabs).toHaveCount(1) + await expect(railTablist.locator('[role="tab"][aria-selected="true"]')).toBeFocused() +}) + +test('global close identifies the focused real xterm panel', async () => { + const page = fixture!.page + const terminalSlot = page.locator('[data-terminal-slot]') + + if (!(await terminalSlot.isVisible())) { + await page.keyboard.press('Control+`') + await terminalSlot.waitFor({ state: 'visible', timeout: 30_000 }) + } + + const railTabs = page.locator('[data-terminal-rail-tab][role="tab"]') + await expect(railTabs).toHaveCount(1, { timeout: 30_000 }) + await page.getByRole('button', { name: 'New terminal' }).click() + await expect(railTabs).toHaveCount(2, { timeout: 30_000 }) + + const focusedTab = page.locator('[data-terminal-rail-tab][role="tab"][aria-selected="true"]') + const terminalId = await focusedTab.getAttribute('data-terminal-rail-tab') + const panelId = await focusedTab.getAttribute('aria-controls') + + if (!terminalId || !panelId) { + throw new Error('Expected the selected terminal tab to identify its xterm panel') + } + + const panel = page.locator(`[role="tabpanel"][id="${panelId}"]`) + const xtermInput = panel.locator('.xterm textarea').first() + await expect(panel).toHaveAttribute('data-terminal-id', terminalId) + await xtermInput.focus() + await expect(xtermInput).toBeFocused() + + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(railTabs).toHaveCount(1) + await expect(railTabs).not.toHaveAttribute('data-terminal-rail-tab', terminalId) + + const survivor = railTabs.first() + await expect(survivor).toBeFocused() + + // The first close must leave ownership in the nested terminal stack. A + // second global close should therefore remove the surviving terminal, not + // dismiss the containing layout pane while leaving its xterm alive. + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(railTabs).toHaveCount(0) +}) + +test('terminal rail keeps focus after its selected survivor finishes initializing', async () => { + const page = fixture!.page + const terminalSlot = page.locator('[data-terminal-slot]') + + if (!(await terminalSlot.isVisible())) { + await page.keyboard.press('Control+`') + await terminalSlot.waitFor({ state: 'visible', timeout: 30_000 }) + } + + const railTabs = page.locator('[data-terminal-rail-tab][role="tab"]') + await expect(railTabs).toHaveCount(1, { timeout: 30_000 }) + + await page.evaluate(() => { + const fontSet = document.fonts + const original = Object.getOwnPropertyDescriptor(fontSet, 'load') + const hadOwnLoad = Boolean(original) + let resolve: (faces: FontFace[]) => void + const pending = new Promise(done => { + resolve = done + }) + const state = window as Window & { + __releaseTerminalFontLoad?: () => void + __terminalFontLoadCalls?: number + } + + state.__terminalFontLoadCalls = 0 + Object.defineProperty(fontSet, 'load', { + configurable: true, + value: () => { + state.__terminalFontLoadCalls = (state.__terminalFontLoadCalls ?? 0) + 1 + + return pending + } + }) + state.__releaseTerminalFontLoad = () => { + resolve([]) + + if (hadOwnLoad && original) { + Object.defineProperty(fontSet, 'load', original) + } else { + Reflect.deleteProperty(fontSet, 'load') + } + } + }) + + await page.getByRole('button', { name: 'New terminal' }).click() + await expect(railTabs).toHaveCount(2, { timeout: 30_000 }) + await expect + .poll(() => page.evaluate(() => (window as Window & { __terminalFontLoadCalls?: number }).__terminalFontLoadCalls ?? 0)) + .toBeGreaterThanOrEqual(3) + + const pendingTab = railTabs.nth(1) + const pendingPanelId = await pendingTab.getAttribute('aria-controls') + + if (!pendingPanelId) { + throw new Error('Expected delayed terminal tab to control a panel') + } + + const pendingTerminal = page.locator(`[role="tabpanel"][id="${pendingPanelId}"] .xterm`) + await expect(pendingTerminal).toHaveCount(0) + + const firstTab = railTabs.first() + await firstTab.click() + await expect(firstTab).toHaveAttribute('aria-selected', 'true') + await firstTab.click({ modifiers: ['Meta'] }) + await expect(railTabs).toHaveCount(1) + + const selectedTab = page.locator('[data-terminal-rail-tab][role="tab"][aria-selected="true"]') + await expect(selectedTab).toBeFocused() + await page.evaluate(() => { + ;(window as Window & { __releaseTerminalFontLoad?: () => void }).__releaseTerminalFontLoad?.() + }) + await expect(pendingTerminal).toHaveCount(1, { timeout: 30_000 }) + await expect(selectedTab).toBeFocused({ timeout: 30_000 }) +}) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 4304138904ff5..6097793d3c52a 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,8 +1,8 @@ import { mainChatOccupied } from '@/app/open-session' -import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' +import { closeFocusedTerminal } from '@/app/right-sidebar/terminal/terminals' import { $workspaceIsPage } from '@/app/routes' import { closeFocusedSessionTab, closeFocusedToolTab } from '@/components/pane-shell/tree/store' -import { isFocusWithin } from '@/lib/keybinds/combo' +import { hasPendingTreeCloseFocusRecovery } from '@/components/pane-shell/tree/tree-focus' import { requestFreshSession } from '@/store/profile' import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -67,21 +67,28 @@ export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: s * with its own tab strip closes ITS tab instead of main's. */ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean { - if (isFocusWithin('[data-terminal]')) { - closeActiveTerminal() + // A busy/deferred tab close owns focus until its result settles. Both the + // renderer shortcut and macOS's native menu call this function, so guarding + // here prevents a second global close from replacing that pending recovery. + if (hasPendingTreeCloseFocusRecovery()) { + return false + } - return true + const terminalClose = closeFocusedTerminal() + + if (terminalClose !== null) { + return terminalClose } // A closeable tab in the focused chat zone (a session tile that's the active // tab) closes outright; the uncloseable workspace tab falls through. - if (closeFocusedSessionTab()) { + if (closeFocusedSessionTab(true)) { return true } // A tool panel zone hosts no chat strip, so the chat rung skips it — but its // tabs close like any other. Without this ⌘W was dead over terminal / logs. - if (closeFocusedToolTab()) { + if (closeFocusedToolTab(true)) { return true } diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 05800c110bdcd..4846ef3edcc15 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -10,7 +10,12 @@ import type { ReadableAtom } from 'nanostores' import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react' import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' -import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { + type PaneCloseResult, + registerPaneCloser, + removeTreePane, + treePanesWithPrefix +} from '@/components/pane-shell/tree/store' import type { PaneStripTool } from '@/components/ui/pane-tab' import { registry } from '@/contrib/registry' import type { TileDock } from '@/store/session-states' @@ -56,8 +61,9 @@ export interface PaneMirror { onTap: () => void, double?: DoubleTapContext ) => boolean - /** Wired as the pane's closer (tab Close). */ - close: (key: string) => void + /** Wired as the pane's closer (tab Close). A confirmation must stay pending + * until it either commits or rejects, so keyboard focus can follow it. */ + close: (key: string) => PaneCloseResult } /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. diff --git a/apps/desktop/src/app/chat/session-tile-close-confirm.test.tsx b/apps/desktop/src/app/chat/session-tile-close-confirm.test.tsx new file mode 100644 index 0000000000000..105a25d5dfd54 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-close-confirm.test.tsx @@ -0,0 +1,263 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { group } from '@/components/pane-shell/tree/model' +import { closeOtherTreeTabs, declareDefaultTree, registerPaneCloser } from '@/components/pane-shell/tree/store' +import { PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' +import { $sessionStates, $sessionTiles } from '@/store/session-states' + +import { requestCloseSessionTile, SessionTabMenu, SessionTileCloseConfirm } from './session-tile' + +const busyTileState: ClientSessionState = { + adoptedRunningTurn: false, + awaitingResponse: false, + branch: '', + busy: true, + cwd: '', + fast: false, + interimBoundaryPending: false, + interrupted: false, + messages: [], + model: '', + needsInput: false, + pendingBranchGroup: null, + personality: '', + provider: '', + reasoningEffort: '', + sawAssistantPayload: false, + serviceTier: '', + storedSessionId: 'busy-session', + streamId: null, + turnStartedAt: null, + usage: null, + yolo: false +} + +function expectClosePromise(value: unknown): Promise { + expect(value).toBeInstanceOf(Promise) + + return value as Promise +} + +function mockDialogExitAnimation() { + vi.stubGlobal('CSS', { escape: (value: string) => value }) + const nativeGetComputedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => { + const styles = nativeGetComputedStyle(element) + + if (element instanceof HTMLElement && element.dataset.slot === 'dialog-content') { + return new Proxy(styles, { + get(target, property, receiver) { + if (property === 'animationName') { + return element.dataset.state === 'closed' ? 'close-running-tab' : 'open-running-tab' + } + + return Reflect.get(target, property, receiver) + } + }) as CSSStyleDeclaration + } + + return styles + }) +} + +describe('busy session tile close confirmation', () => { + beforeEach(() => { + $sessionStates.set({ 'busy-runtime': busyTileState }) + $sessionTiles.set([{ runtimeId: 'busy-runtime', storedSessionId: 'busy-session' }]) + }) + + afterEach(() => { + cleanup() + registerPaneCloser('session-tile:busy-session') + registerPaneCloser('session-tile:other-busy-session') + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() + $sessionStates.set({}) + $sessionTiles.set([]) + }) + + it('opens the session tab menu when right-clicking its close control', async () => { + render( + + + Busy session + + + ) + + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + fireEvent.pointerDown(closeButton, { button: 2, pointerType: 'mouse' }) + fireEvent.contextMenu(closeButton) + + expect(await screen.findByRole('menu')).toBeTruthy() + expect(screen.getByRole('menuitem', { name: /^close$/i })).toBeTruthy() + }) + + it('keeps a busy close pending through StrictMode dialog mounting', async () => { + render( + + + + ) + + let close: Promise | undefined + let settled = false + act(() => { + close = expectClosePromise(requestCloseSessionTile('busy-session')) + void close.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + }) + + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + await act(async () => { + await Promise.resolve() + }) + + expect(settled).toBe(false) + expect($sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['busy-session']) + }) + + it('keeps a busy close pending until the confirmation is accepted', async () => { + render() + + let close: Promise | undefined + act(() => { + close = expectClosePromise(requestCloseSessionTile('busy-session')) + }) + + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + await Promise.resolve() + expect($sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['busy-session']) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Close tab' })) + await close! + }) + + expect($sessionTiles.get()).toEqual([]) + }) + + it('rejects the pending close when the confirmation is canceled', async () => { + render() + + let close: Promise | undefined + act(() => { + close = expectClosePromise(requestCloseSessionTile('busy-session')) + }) + + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + vi.useFakeTimers() + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + }) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + await expect(close!).rejects.toThrow('Session tab close canceled') + expect($sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['busy-session']) + }) + + it('rejects a pending close when its confirmation unmounts', async () => { + const view = render() + + let close: Promise | undefined + act(() => { + close = expectClosePromise(requestCloseSessionTile('busy-session')) + }) + + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + view.unmount() + + await expect(close!).rejects.toThrow('Session tab close canceled') + }) + + it('keeps a confirmed busy close pending through the dialog exit animation', async () => { + mockDialogExitAnimation() + + render() + + let close: Promise | undefined + let settled = false + act(() => { + close = expectClosePromise(requestCloseSessionTile('busy-session')) + void close.then(() => { + settled = true + }) + }) + + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + vi.useFakeTimers() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Close tab' })) + await vi.advanceTimersByTimeAsync(600) + }) + + const dialog = screen.getByRole('dialog', { name: 'Close running tab?' }) + expect(dialog.dataset.state).toBe('closed') + expect(settled).toBe(false) + + await act(async () => { + const animationEnd = new Event('animationend', { bubbles: true }) + Object.defineProperty(animationEnd, 'animationName', { value: 'close-running-tab' }) + dialog.dispatchEvent(animationEnd) + await Promise.resolve() + }) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + expect(settled).toBe(true) + await expect(close).resolves.toBeUndefined() + }) + + it('serializes busy session confirmations during Close Others', async () => { + const otherBusyTileState = { ...busyTileState, storedSessionId: 'other-busy-session' } + $sessionStates.set({ 'busy-runtime': busyTileState, 'other-busy-runtime': otherBusyTileState }) + $sessionTiles.set([ + { runtimeId: 'busy-runtime', storedSessionId: 'busy-session' }, + { runtimeId: 'other-busy-runtime', storedSessionId: 'other-busy-session' } + ]) + declareDefaultTree( + group(['workspace', 'session-tile:busy-session', 'session-tile:other-busy-session'], { + active: 'workspace', + id: 'busy-close-group' + }) + ) + registerPaneCloser('session-tile:busy-session', () => requestCloseSessionTile('busy-session')) + registerPaneCloser('session-tile:other-busy-session', () => requestCloseSessionTile('other-busy-session')) + render() + + const completion = expectClosePromise(closeOtherTreeTabs('workspace')) + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Close tab' })) + + await waitFor(() => { + expect($sessionTiles.get().map(tile => tile.storedSessionId)).toEqual(['other-busy-session']) + expect(screen.getByRole('dialog', { name: 'Close running tab?' }).dataset.state).toBe('open') + expect(screen.getByRole('button', { name: /^(Close tab|Done)$/ }).hasAttribute('disabled')).toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: /^(Close tab|Done)$/ })) + await completion + expect($sessionTiles.get()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 27d2cebf68ecf..655c21bc6118f 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -27,7 +27,13 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status' import { findGroupOfPane } from '@/components/pane-shell/tree/model' -import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store' +import { + $layoutTree, + closeTreePane, + moveTreePane, + type PaneCloseResult, + setTreeGroupHeaderHidden +} from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { transcribeAudio } from '@/hermes' @@ -400,39 +406,134 @@ function tileDragPayload(storedSessionId: string): SessionDragPayload { // input) doesn't close silently. // --------------------------------------------------------------------------- -/** Stored id awaiting close confirmation (null = no dialog). */ -const $confirmCloseTile = atom(null) +interface PendingSessionTileClose { + completion: Promise + confirmed: boolean + id: number + phase: 'closing' | 'open' + reject: (reason: Error) => void + resolve: () => void + storedSessionId: string +} + +/** Pending busy close (null = no dialog). Its promise is the pane closer's + * lifecycle contract: resolve only after the dialog has closed, reject on + * cancel, so tree focus never moves behind the modal. */ +const $confirmCloseTile = atom(null) +let nextPendingSessionTileCloseId = 0 + +function closeCanceledError(): Error { + return new Error('Session tab close canceled') +} + +/** Start Dialog's close transition but keep the closer pending until Radix + * unmounts its focus scope. A new request stays blocked during that exit. */ +function startPendingSessionTileCloseExit() { + const pending = $confirmCloseTile.get() + + if (pending?.phase === 'open') { + $confirmCloseTile.set({ ...pending, phase: 'closing' }) + } +} + +/** Finish the closer only after Dialog's focus scope has run close autofocus. + * The root focus coordinator can then observe body/stale focus and recover it. */ +function settlePendingSessionTileClose(expectedId?: number) { + const pending = $confirmCloseTile.get() + + if (!pending || (expectedId !== undefined && pending.id !== expectedId)) { + return + } + + $confirmCloseTile.set(null) + + if (pending.confirmed) { + pending.resolve() + } else { + pending.reject(closeCanceledError()) + } +} /** The tile closer, gated: a quiet session closes immediately; a busy or * input-blocked one asks first. One state read — the tile's runtime slice. */ -export function requestCloseSessionTile(storedSessionId: string): void { +export function requestCloseSessionTile(storedSessionId: string): Promise { const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined if (state?.busy || state?.awaitingResponse || state?.needsInput) { - $confirmCloseTile.set(storedSessionId) - } else { - closeSessionTile(storedSessionId) + const existing = $confirmCloseTile.get() + + if (existing) { + if (existing.storedSessionId === storedSessionId) { + return existing.completion + } + + const blocked = Promise.reject(new Error('Another session tab close is awaiting confirmation')) + + void blocked.catch(() => undefined) + + return blocked + } + + let reject!: (reason: Error) => void + let resolve!: () => void + + const completion = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + // A context-menu callback intentionally ignores the return value. Keep its + // cancel rejection observed while TreeGroup still receives the same promise. + void completion.catch(() => undefined) + $confirmCloseTile.set({ + completion, + confirmed: false, + id: ++nextPendingSessionTileCloseId, + phase: 'open', + reject, + resolve, + storedSessionId + }) + + return completion } + + closeSessionTile(storedSessionId) + + return Promise.resolve() } /** Mounted once at the shell root: the "Close running tab?" confirmation. */ export function SessionTileCloseConfirm() { const { t } = useI18n() - const storedSessionId = useStore($confirmCloseTile) + const pending = useStore($confirmCloseTile) + const pendingId = pending?.id + + useEffect( + () => () => { + settlePendingSessionTileClose() + }, + [] + ) return ( $confirmCloseTile.set(null)} + key={pendingId ?? 'idle'} + onClose={startPendingSessionTileCloseExit} + onCloseAutoFocus={() => settlePendingSessionTileClose(pendingId)} onConfirm={() => { - if (storedSessionId) { - closeSessionTile(storedSessionId) + const current = $confirmCloseTile.get() + + if (current) { + $confirmCloseTile.set({ ...current, confirmed: true }) + closeSessionTile(current.storedSessionId) } }} - open={storedSessionId !== null} + open={pending?.phase === 'open'} title={t.zones.closeRunningTitle} /> ) @@ -506,7 +607,7 @@ export function SessionTabMenu({ }: { children: React.ReactElement /** Close this tab (tiles; the main tab passes nothing). */ - onClose?: () => void + onClose?: () => PaneCloseResult /** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */ onHideTabBar?: () => void storedSessionId: string @@ -518,24 +619,24 @@ export function SessionTabMenu({ const pinned = pinnedSessionIds.includes(pinId) return ( - event.stopPropagation()}> - void sessionTileDelegate()?.archiveSession(storedSessionId)} - onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)} - onClose={onClose} - onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)} - onHideTabBar={onHideTabBar} - onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))} - pinned={pinned} - profile={profile} - sessionId={storedSessionId} - surface="tab" - tabPaneId={tabPaneId} - title={title} - > + void sessionTileDelegate()?.archiveSession(storedSessionId)} + onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)} + onClose={onClose} + onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)} + onHideTabBar={onHideTabBar} + onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))} + pinned={pinned} + profile={profile} + sessionId={storedSessionId} + surface="tab" + tabPaneId={tabPaneId} + title={title} + > + event.stopPropagation()}> {children} - - + + ) } diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx index 559d648407bce..498c9063f40a3 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx @@ -2,6 +2,8 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runTreeCloseWithFocusRecovery } from '@/components/pane-shell/tree/tree-focus' + import { SessionActionsMenu } from './session-actions-menu' afterEach(cleanup) @@ -14,8 +16,10 @@ vi.mock('@/components/pane-shell/tree/store', () => ({ closeAllTreeTabs: vi.fn(), closeOtherTreeTabs: vi.fn(), closeTreeTabsToRight: vi.fn(), + treePaneGroupId: vi.fn(() => 'grp-session'), treeTabCloseTargets: vi.fn(() => null) })) +vi.mock('@/components/pane-shell/tree/tree-focus', () => ({ runTreeCloseWithFocusRecovery: vi.fn() })) vi.mock('@/hermes', () => ({ renameSession: vi.fn() })) vi.mock('@/i18n', () => ({ useI18n: () => ({ @@ -94,6 +98,16 @@ function renderMenu() { ) } +function renderTabMenu(onClose: () => void) { + return render( + + + + ) +} + describe('SessionActionsMenu', () => { it('opens the dropdown on click without a tooltip on the kebab', async () => { renderMenu() @@ -113,4 +127,18 @@ describe('SessionActionsMenu', () => { expect(screen.getByRole('menuitem', { name: /rename/i })).toBeTruthy() expect(screen.getByRole('menuitem', { name: /archive/i })).toBeTruthy() }) + + it('routes a tab context-menu close through focus recovery', async () => { + const onClose = vi.fn() + renderTabMenu(onClose) + + const trigger = screen.getByRole('button', { name: 'Session actions' }) + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.click(trigger) + fireEvent.click(await screen.findByRole('menuitem', { name: /^close$/i })) + + expect(runTreeCloseWithFocusRecovery).toHaveBeenCalledWith('session-tile:s1', onClose, 'grp-session') + expect(onClose).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 0a32e1881f85b..fc162639659bf 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -7,9 +7,12 @@ import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, + type PaneCloseResult, reloadTreePane, + treePaneGroupId, treeTabCloseTargets } from '@/components/pane-shell/tree/store' +import { runTreeCloseWithFocusRecovery } from '@/components/pane-shell/tree/tree-focus' import { type ActionItemSpec, ActionsContextMenu, @@ -101,7 +104,7 @@ interface SessionActions { onDelete?: () => void /** Close this surface (a tile tab) — omitted where nothing closes (sidebar * rows, the main tab). */ - onClose?: () => void + onClose?: () => PaneCloseResult /** TAB surfaces: the session is already a tab, so "Open in new tab" is * nonsense there — sidebar rows/dropdowns keep it. */ surface?: 'row' | 'tab' @@ -282,6 +285,14 @@ function useSessionActions({ // TAB — verbs that act on the strip (tabs only; a row isn't a tab). const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null + const closeTab = (close: () => PaneCloseResult) => { + if (tabPaneId) { + runTreeCloseWithFocusRecovery(tabPaneId, close, treePaneGroupId(tabPaneId)) + } else { + close() + } + } + const tabItems: ActionItemSpec[] = surface === 'tab' ? [ @@ -305,7 +316,7 @@ function useSessionActions({ label: t.common.close, onSelect: () => { triggerHaptic('selection') - onClose() + closeTab(onClose) } }) ] @@ -318,7 +329,7 @@ function useSessionActions({ label: t.zones.closeOthers, onSelect: () => { triggerHaptic('selection') - closeOtherTreeTabs(tabPaneId) + closeTab(() => closeOtherTreeTabs(tabPaneId)) } }), spec({ @@ -327,7 +338,7 @@ function useSessionActions({ label: t.zones.closeToRight, onSelect: () => { triggerHaptic('selection') - closeTreeTabsToRight(tabPaneId) + closeTab(() => closeTreeTabsToRight(tabPaneId)) } }), spec({ @@ -336,7 +347,7 @@ function useSessionActions({ label: t.zones.closeAll, onSelect: () => { triggerHaptic('selection') - closeAllTreeTabs(tabPaneId) + closeTab(() => closeAllTreeTabs(tabPaneId)) } }) ] diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 1e2107f517686..9ca395f762482 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -168,8 +168,11 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { } const showFiles = () => { + if (isPaneVisible('terminal')) { + togglePaneVisible('terminal') + } + setFileBrowserOpen(true) - setTerminalTakeover(false) } handlersRef.current = { diff --git a/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.test.ts b/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.test.ts new file mode 100644 index 0000000000000..00356524da765 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + $treeFocusRequest, + clearTreeFocusRequest, + requestTreeFocusAfterClose, + requestTreeFocusAfterRestore, + settleTreeFocusAfterClose +} from '@/components/pane-shell/tree/tree-focus' + +import { focusTerminalUnlessRailOwnsFocus, TERMINAL_RAIL_FOCUS_HANDOFF_ATTR } from './focus-handoff' + +afterEach(() => { + $treeFocusRequest.set(null) + document.body.replaceChildren() +}) + +describe('focusTerminalUnlessRailOwnsFocus', () => { + it('preserves a selected terminal rail tab during a close handoff', () => { + const terminal = { focus: vi.fn() } + const tab = document.createElement('button') + tab.setAttribute('aria-selected', 'true') + tab.setAttribute('data-terminal-rail-tab', 'terminal-1') + tab.setAttribute(TERMINAL_RAIL_FOCUS_HANDOFF_ATTR, '') + document.body.append(tab) + tab.focus() + + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(false) + expect(terminal.focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(tab) + }) + + it('focuses the terminal when the rail does not own a handoff', () => { + const terminal = { focus: vi.fn() } + + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(true) + expect(terminal.focus).toHaveBeenCalledOnce() + }) + + it('does not steal a pending or settled tree-close recovery', () => { + const terminal = { focus: vi.fn() } + const request = requestTreeFocusAfterClose('plugin-pane') + + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(false) + expect(terminal.focus).not.toHaveBeenCalled() + + settleTreeFocusAfterClose(request) + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(false) + expect(terminal.focus).not.toHaveBeenCalled() + + clearTreeFocusRequest(request) + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(true) + expect(terminal.focus).toHaveBeenCalledOnce() + }) + + it('does not steal a tree restore handoff before the restored tab receives focus', () => { + const terminal = { focus: vi.fn() } + + requestTreeFocusAfterRestore('grp-tools', 'terminal') + const request = $treeFocusRequest.get()! + + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(false) + expect(terminal.focus).not.toHaveBeenCalled() + + clearTreeFocusRequest(request) + expect(focusTerminalUnlessRailOwnsFocus(terminal)).toBe(true) + expect(terminal.focus).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.ts b/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.ts new file mode 100644 index 0000000000000..842045eb769b7 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/focus-handoff.ts @@ -0,0 +1,53 @@ +import { atom } from 'nanostores' + +import { hasTreeFocusRecovery } from '@/components/pane-shell/tree/tree-focus' + +export const TERMINAL_RAIL_FOCUS_HANDOFF_ATTR = 'data-terminal-rail-focus-handoff' +export const $terminalRailFocusHandoff = atom(false) + +interface FocusableTerminal { + focus: () => void +} + +/** A close/roving handoff intentionally leaves focus on the selected rail tab. + * Late xterm initialization must not claim it back. */ +function terminalRailOwnsFocus(): boolean { + if (typeof document === 'undefined') { + return false + } + + const active = document.activeElement + + return ( + active instanceof HTMLElement && + active.matches(`[${TERMINAL_RAIL_FOCUS_HANDOFF_ATTR}][data-terminal-rail-tab][aria-selected="true"]`) + ) +} + +/** Whether a terminal-rail tab initiated the current close command. */ +export function terminalRailTabHasFocus(): boolean { + if (typeof document === 'undefined') { + return false + } + + return document.activeElement instanceof HTMLElement && document.activeElement.matches('[data-terminal-rail-tab]') +} + +/** Request post-commit focus for the selected terminal rail tab. */ +export function requestTerminalRailFocusHandoff(): void { + $terminalRailFocusHandoff.set(true) +} + +export function clearTerminalRailFocusHandoff(): void { + $terminalRailFocusHandoff.set(false) +} + +export function focusTerminalUnlessRailOwnsFocus(terminal: FocusableTerminal): boolean { + if (hasTreeFocusRecovery() || terminalRailOwnsFocus()) { + return false + } + + terminal.focus() + + return true +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx index 8db59ca9fa1b0..a9fad5ed32ab5 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx @@ -1,10 +1,22 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { closeActiveTab } from '@/app/chat/close-tab' +import { $treeFocusRequest, requestTreeFocusAfterClose } from '@/components/pane-shell/tree/tree-focus' import { $bindings } from '@/store/keybinds' import { TerminalRail } from './rail' -import { $activeTerminalId, $terminals } from './terminals' +import { $activeTerminalId, $terminals, closeFocusedTerminal } from './terminals' +import { TerminalWorkspace } from './workspace' + +vi.mock('./instance', () => ({ + AgentTerminalInstance: ({ id }: { id: string }) => ( + - - + +
+
+ + ) : null, + title: id + }) + ) + } + + markCollapsePane('terminal') + markCollapsePane('logs') +}) + +afterEach(() => { + cleanup() + $layoutEditMode.set(false) + $collapsedTreeSides.set(new Set()) + $layoutTree.set(null) + $treeFocusRequest.set(null) + $sessionStates.set({}) + $sessionTiles.set([]) + registerPaneCloser('delayed-close-test') + registerPaneCloser('busy-session-pane') + disposers.splice(0).forEach(dispose => dispose()) +}) + +const zoneAt = (index: number) => { + const node = $layoutTree.get()! + + return (node.type === 'split' ? node.children[index] : node) as never +} + +const tabControl = (paneId: string) => + window.document.querySelector(`[data-tree-tab="${paneId}"] [data-pane-tab-control="true"]`) + +describe('TreeGroup tab keyboard interaction', () => { + it('does not let an older focus request clear a newer one', () => { + const older = requestTreeFocusAfterClose('terminal') + settleTreeFocusAfterClose(older) + const newer = requestTreeFocusAfterClose('logs') + + clearTreeFocusRequest(older) + expect($treeFocusRequest.get()).toBe(newer) + + clearTreeFocusRequest(newer) + expect($treeFocusRequest.get()).toBeNull() + }) + + it('associates rendered tabs with their kept-alive tab panels', () => { + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + render() + + for (const paneId of ['terminal', 'logs']) { + const tab = tabControl(paneId)! + const panelId = tab.getAttribute('aria-controls') + const panel = window.document.getElementById(panelId!) + + expect(tab.id).not.toBe('') + expect(panelId).toBeTruthy() + expect(panel?.getAttribute('aria-labelledby')).toBe(tab.id) + expect(panel?.getAttribute('role')).toBe('tabpanel') + } + }) + + it.each(['column', 'row'] as const)( + 'keeps minimized %s tabs selected and associated with hidden panels', + parentAxis => { + declareDefaultTree( + split(parentAxis, [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const tab = tabControl('terminal')! + const panel = window.document.getElementById(tab.getAttribute('aria-controls')!) + + expect(tab.getAttribute('aria-selected')).toBe('true') + expect(panel?.getAttribute('aria-labelledby')).toBe(tab.id) + expect(panel?.getAttribute('role')).toBe('tabpanel') + expect(panel?.hidden).toBe(true) + } + ) + + it('recovers focus after ⌘W closes a focused tool tab', async () => { + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + render() + + const terminal = tabControl('terminal')! + terminal.focus() + noteActiveTreeGroup('grp-tools') + act(() => { + expect(closeActiveTab()).toBe(true) + }) + + await waitFor(() => { + expect(tabControl('terminal')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('logs')) + }) + }) + + it('recovers focus after ⌘W closes a focused session tab', async () => { + declareDefaultTree( + split('column', [ + group(['workspace', 'delayed-close-test'], { active: 'delayed-close-test', id: 'grp-main' }), + group(['terminal'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + render() + + const sessionTab = tabControl('delayed-close-test')! + sessionTab.focus() + noteActiveTreeGroup('grp-main') + act(() => { + expect(closeActiveTab()).toBe(true) + }) + + await waitFor(() => { + expect(tabControl('delayed-close-test')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('terminal')) + }) + }) + + it('keeps deferred global ⌘W recovery in the focused split-off tab group', async () => { + declareDefaultTree( + split('row', [ + group(['workspace', 'delayed-close-test'], { active: 'workspace', id: 'grp-main' }), + group(['focused-side-a', 'focused-side-b'], { active: 'focused-side-b', id: 'grp-side' }) + ]) + ) + let closeCompletion: Promise | undefined + + let finishClose = () => {} + registerPaneCloser( + 'focused-side-b', + () => { + closeCompletion = new Promise(resolve => { + finishClose = () => { + dismissTreePane('focused-side-b') + resolve() + } + }) + + return closeCompletion + } + ) + render() + + try { + const focusedSide = tabControl('focused-side-b')! + focusedSide.focus() + noteActiveTreeGroup('grp-side') + expect(tabControl('workspace')?.getAttribute('aria-selected')).toBe('true') + act(() => { + expect(closeActiveTab()).toBe(true) + }) + expect(tabControl('focused-side-b')).toBe(focusedSide) + + await act(async () => { + finishClose() + await closeCompletion + }) + + await waitFor(() => { + expect(tabControl('focused-side-b')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('focused-side-a')) + }) + } finally { + act(() => registerPaneCloser('focused-side-b')) + } + }) + + it('keeps deferred global ⌘W recovery in the focused group when its raw successor is hidden', async () => { + declareDefaultTree( + split('row', [ + group(['workspace', 'delayed-close-test'], { active: 'workspace', id: 'grp-main' }), + group(['focused-side-a', 'files', 'focused-side-b'], { active: 'focused-side-b', id: 'grp-side' }) + ]) + ) + let closeCompletion: Promise | undefined + const closeFiles = vi.fn(() => setTreePaneHidden('files', true)) + + let finishClose = () => {} + registerPaneCloser('files', closeFiles) + registerPaneCloser( + 'focused-side-b', + () => { + closeCompletion = new Promise(resolve => { + finishClose = () => { + dismissTreePane('focused-side-b') + resolve() + } + }) + + return closeCompletion + } + ) + act(() => setTreePaneHidden('files', true)) + render() + + try { + const focusedSide = tabControl('focused-side-b')! + focusedSide.focus() + noteActiveTreeGroup('grp-side') + expect(tabControl('workspace')?.getAttribute('aria-selected')).toBe('true') + expect(tabControl('files')).toBeNull() + act(() => { + expect(closeActiveTab()).toBe(true) + }) + + await act(async () => { + finishClose() + await closeCompletion + }) + + await waitFor(() => { + expect(findGroup($layoutTree.get()!, 'grp-side')?.active).toBe('files') + expect(tabControl('focused-side-b')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('focused-side-a')) + }) + + act(() => $layoutEditMode.set(true)) + await waitFor(() => expect(tabControl('files')?.getAttribute('aria-selected')).toBe('true')) + + act(() => { + expect(closeActiveTab()).toBe(true) + }) + await waitFor(() => { + expect(closeFiles).toHaveBeenCalledTimes(1) + expect(findGroup($layoutTree.get()!, 'grp-side')?.panes).toEqual(['focused-side-a', 'files']) + }) + + act(() => $layoutEditMode.set(false)) + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('focused-side-a'))) + + act(() => { + expect(closeActiveTab()).toBe(true) + }) + await waitFor(() => { + expect(closeFiles).toHaveBeenCalledTimes(1) + expect(tabControl('focused-side-a')).toBeNull() + expect(findGroup($layoutTree.get()!, 'grp-side')?.panes).toEqual(['files']) + }) + } finally { + act(() => registerPaneCloser('files')) + act(() => registerPaneCloser('focused-side-b')) + } + }) + + it('uses roving tabs and restores focus to the active tab after keyboard close', async () => { + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + const view = render() + const terminal = tabControl('terminal')! + const logs = tabControl('logs')! + + expect(terminal.getAttribute('tabindex')).toBe('0') + expect(logs.getAttribute('tabindex')).toBe('-1') + + terminal.focus() + fireEvent.keyDown(terminal, { key: 'ArrowRight' }) + expect(window.document.activeElement).toBe(logs) + + view.rerender() + expect(tabControl('logs')?.getAttribute('aria-selected')).toBe('true') + + const logsForLeft = tabControl('logs')! + logsForLeft.focus() + fireEvent.keyDown(logsForLeft, { key: 'ArrowLeft' }) + expect(window.document.activeElement).toBe(terminal) + + view.rerender() + expect(tabControl('terminal')?.getAttribute('aria-selected')).toBe('true') + + const terminalForEnd = tabControl('terminal')! + terminalForEnd.focus() + fireEvent.keyDown(terminalForEnd, { key: 'End' }) + view.rerender() + expect(tabControl('logs')?.getAttribute('aria-selected')).toBe('true') + + const logsForHome = tabControl('logs')! + logsForHome.focus() + fireEvent.keyDown(logsForHome, { key: 'Home' }) + view.rerender() + expect(tabControl('terminal')?.getAttribute('aria-selected')).toBe('true') + + const logsForSpace = tabControl('logs')! + logsForSpace.focus() + fireEvent.keyDown(logsForSpace, { key: ' ' }) + view.rerender() + expect(tabControl('logs')?.getAttribute('aria-selected')).toBe('true') + + const logsForEnter = tabControl('logs')! + logsForEnter.focus() + fireEvent.keyDown(logsForEnter, { key: 'Enter' }) + view.rerender() + expect(tabControl('logs')?.getAttribute('aria-selected')).toBe('true') + + const close = window.document.querySelector('[data-tree-tab="terminal"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + fireEvent.click(close) + view.rerender() + + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('logs'))) + }) + + it('keeps close-button focus recovery pending until a registered closer confirms', async () => { + let confirmClose: (() => void) | undefined + registerPaneCloser( + 'delayed-close-test', + () => + new Promise(resolve => { + confirmClose = () => { + dismissTreePane('delayed-close-test') + resolve() + } + }) + ) + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'delayed-close-test'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + const view = render() + const closeSelector = '[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]' + await waitFor(() => expect(window.document.querySelector(closeSelector)).toBeTruthy()) + const close = window.document.querySelector(closeSelector)! + act(() => close.focus()) + fireEvent.click(close) + view.rerender() + + expect(tabControl('delayed-close-test')).toBeTruthy() + expect(confirmClose).toBeTypeOf('function') + + act(() => confirmClose?.()) + view.rerender() + + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('terminal'))) + act(() => registerPaneCloser('delayed-close-test')) + }) + + it('keeps context-menu close recovery pending until a registered closer confirms', async () => { + let confirmClose: (() => void) | undefined + registerPaneCloser( + 'delayed-close-test', + () => + new Promise(resolve => { + confirmClose = () => { + dismissTreePane('delayed-close-test') + resolve() + } + }) + ) + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'delayed-close-test'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + render() + + try { + const tab = window.document.querySelector('[data-tree-tab="delayed-close-test"]')! + + fireEvent.pointerDown(tab, { button: 2, pointerType: 'mouse' }) + fireEvent.contextMenu(tab, { button: 2 }) + fireEvent.click(await screen.findByRole('menuitem', { name: /^close$/i })) + + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'delayed-close-test', status: 'pending' }) + expect(confirmClose).toBeTypeOf('function') + + act(() => confirmClose?.()) + + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('terminal'))) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('waits for the real busy-session confirmation before recovering close focus', async () => { + $sessionStates.set({ 'busy-runtime': busySessionState }) + $sessionTiles.set([{ runtimeId: 'busy-runtime', storedSessionId: 'busy-session' }]) + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'busy-session-pane'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + + let completion: Promise | undefined + registerPaneCloser('busy-session-pane', () => (completion = requestCloseSessionTile('busy-session'))) + + const stopMirror = $sessionTiles.listen(tiles => { + if (!tiles.some(tile => tile.storedSessionId === 'busy-session')) { + dismissTreePane('busy-session-pane') + } + }) + + render( + <> + + + + ) + + try { + const close = window.document.querySelector( + '[data-tree-tab="busy-session-pane"] [data-pane-tab-close="true"]' + )! + + act(() => close.focus()) + act(() => fireEvent.click(close)) + + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'busy-session-pane', status: 'pending' }) + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + + act(() => fireEvent.click(screen.getByRole('button', { name: 'Close tab' }))) + + await waitFor(() => expect(tabControl('busy-session-pane')).toBeNull()) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'busy-session-pane', status: 'pending' }) + + await act(async () => { + await completion! + }) + + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('terminal'))) + } finally { + act(() => { + stopMirror() + registerPaneCloser('busy-session-pane') + }) + } + }) + + it('does not let a global close supersede a pending busy close', async () => { + $sessionStates.set({ 'busy-runtime': busySessionState }) + $sessionTiles.set([{ runtimeId: 'busy-runtime', storedSessionId: 'busy-session' }]) + declareDefaultTree(group(['workspace', 'busy-session-pane', 'delayed-close-test'], { active: 'busy-session-pane', id: 'grp-main' })) + let completion: Promise | undefined + registerPaneCloser('busy-session-pane', () => (completion = requestCloseSessionTile('busy-session'))) + + const stopMirror = $sessionTiles.listen(tiles => { + if (!tiles.some(tile => tile.storedSessionId === 'busy-session')) { + dismissTreePane('busy-session-pane') + } + }) + + render( + <> + + + + ) + + try { + const close = window.document.querySelector( + '[data-tree-tab="busy-session-pane"] [data-pane-tab-close="true"]' + )! + + act(() => fireEvent.click(close)) + expect(await screen.findByRole('dialog', { name: 'Close running tab?' })).toBeTruthy() + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'busy-session-pane', status: 'pending' }) + + act(() => { + noteHoveredTreeGroup('grp-main') + expect(cycleTreeTabInFocusedZone(1)).toBe('delayed-close-test') + }) + + let closed = true + act(() => { + closed = closeActiveTab() + }) + + expect(closed).toBe(false) + expect(tabControl('delayed-close-test')).not.toBeNull() + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'busy-session-pane', status: 'pending' }) + + act(() => fireEvent.click(screen.getByRole('button', { name: 'Close tab' }))) + + await waitFor(() => expect(tabControl('busy-session-pane')).toBeNull()) + await act(async () => { + await completion! + }) + await waitFor(() => expect(window.document.activeElement).toBe(tabControl('delayed-close-test'))) + } finally { + act(() => { + noteHoveredTreeGroup(null) + stopMirror() + registerPaneCloser('busy-session-pane') + }) + } + }) + + it('moves focus from a vertical rail tab to its restored horizontal tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + terminal.focus() + fireEvent.keyDown(terminal, { key: 'ArrowDown' }) + + await waitFor(() => { + const logs = tabControl('logs') + + expect(logs?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(logs) + }) + }) + + it('wraps vertical rail navigation and ignores modified arrow keys', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + terminal.focus() + fireEvent.keyDown(terminal, { key: 'ArrowDown', metaKey: true }) + expect(window.document.activeElement).toBe(terminal) + expect(findGroup($layoutTree.get()!, 'grp-tools')?.active).toBe('terminal') + + fireEvent.keyDown(terminal, { key: 'ArrowUp' }) + + await waitFor(() => { + const logs = tabControl('logs') + + expect(logs?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(logs) + }) + }) + + it('keeps keyboard focus when Space restores a vertical rail tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const logs = tabControl('logs')! + logs.focus() + fireEvent.keyDown(logs, { key: ' ' }) + + await waitFor(() => { + const restoredLogs = tabControl('logs') + + expect(restoredLogs?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredLogs) + }) + }) + + it('keeps focus when a primary click restores a focused vertical rail tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + act(() => { + terminal.focus() + fireEvent.click(terminal) + }) + + await waitFor(() => { + const restoredTerminal = tabControl('terminal') + + expect(restoredTerminal?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredTerminal) + }) + }) + + it('keeps focus when the vertical rail background restores its active tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + const rail = terminal.closest('[role="tablist"]')?.parentElement + + expect(rail).not.toBeNull() + act(() => { + terminal.focus() + fireEvent.click(rail!) + }) + + await waitFor(() => { + const restoredTerminal = tabControl('terminal') + + expect(restoredTerminal?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredTerminal) + }) + }) + + it('keeps focus when the vertical rail context menu restores its active tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + act(() => { + terminal.focus() + fireEvent.contextMenu(terminal, { button: 2 }) + }) + fireEvent.click(await screen.findByRole('menuitem', { name: /^restore$/i })) + + await waitFor(() => { + const restoredTerminal = tabControl('terminal') + + expect(restoredTerminal?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredTerminal) + }) + }) + + it('keeps focus when a primary click restores a focused horizontal minimized tab', async () => { + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + act(() => { + terminal.focus() + fireEvent.click(terminal) + }) + + await waitFor(() => { + const restoredTerminal = tabControl('terminal') + + expect(restoredTerminal?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredTerminal) + }) + }) + + it('keeps focus when the horizontal minimized tab context menu restores its active tab', async () => { + declareDefaultTree( + split('column', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'logs'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + setTreeGroupMinimized('grp-tools', true) + render() + + const terminal = tabControl('terminal')! + act(() => { + terminal.focus() + fireEvent.contextMenu(terminal, { button: 2 }) + }) + fireEvent.click(await screen.findByRole('menuitem', { name: /^restore$/i })) + + await waitFor(() => { + const restoredTerminal = tabControl('terminal') + + expect(restoredTerminal?.getAttribute('aria-selected')).toBe('true') + expect(window.document.activeElement).toBe(restoredTerminal) + }) + }) + + it('moves focus to the surviving workspace composer when closing a lone tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + render() + + const close = window.document.querySelector('[data-tree-tab="terminal"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect(findGroup($layoutTree.get()!, 'grp-tools')).toBeNull() + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + }) + + it('recovers focus after a registered closer removes a lone tab group', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + + let finishClose = () => {} + act(() => { + registerPaneCloser( + 'delayed-close-test', + () => + new Promise(resolve => { + finishClose = () => { + dismissTreePane('delayed-close-test') + resolve() + } + }) + ) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + expect(window.document.activeElement).toBe(close) + + act(() => finishClose()) + + await waitFor(() => { + expect(findGroup($layoutTree.get()!, 'grp-tools')).toBeNull() + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('keeps recovery pending for an inactive tab while its registered close is deferred', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'delayed-close-test'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + + let finishClose = () => {} + act(() => { + registerPaneCloser('delayed-close-test', () => + new Promise(resolve => { + finishClose = () => { + dismissTreePane('delayed-close-test') + resolve() + } + }) + ) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, 30)) + }) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'delayed-close-test', kind: 'close' }) + + act(() => finishClose()) + + await waitFor(() => { + expect(tabControl('terminal')).toBeTruthy() + expect(window.document.activeElement).toBe(tabControl('terminal')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('recovers focus after a synchronous registered close removes an inactive tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'delayed-close-test'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + act(() => registerPaneCloser('delayed-close-test', () => dismissTreePane('delayed-close-test'))) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect(tabControl('delayed-close-test')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('terminal')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('moves pointer-close focus from the composer to the surviving selected tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['terminal', 'delayed-close-test'], { active: 'terminal', id: 'grp-tools' }) + ]) + ) + act(() => registerPaneCloser('delayed-close-test', () => dismissTreePane('delayed-close-test'))) + render() + + try { + const composer = window.document.querySelector('[data-slot="composer-rich-input"]')! + + const close = window.document.querySelector( + '[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]' + )! + + act(() => composer.focus()) + expect(window.document.activeElement).toBe(composer) + + act(() => fireEvent.pointerDown(close, { button: 0, pointerType: 'mouse' })) + expect(window.document.activeElement).toBe(close) + act(() => fireEvent.click(close, { button: 0 })) + + await waitFor(() => { + expect(tabControl('delayed-close-test')).toBeNull() + expect(window.document.activeElement).toBe(tabControl('terminal')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('recovers focus into the workspace composer after a middle-click close removes the focused tab', async () => { + declareDefaultTree(group(['workspace', 'delayed-close-test'], { active: 'delayed-close-test', id: 'grp-main' })) + act(() => registerPaneCloser('delayed-close-test', () => dismissTreePane('delayed-close-test'))) + render() + + try { + const tab = tabControl('delayed-close-test')! + act(() => tab.focus()) + + act(() => { + fireEvent.pointerDown(tab, { button: 1, pointerType: 'mouse' }) + fireEvent.pointerUp(tab, { button: 1, pointerType: 'mouse' }) + }) + + await waitFor(() => { + expect(tabControl('delayed-close-test')).toBeNull() + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('cancels pending focus recovery when a registered close is rejected', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + + let rejectClose = () => {} + act(() => { + registerPaneCloser('delayed-close-test', () => { + const result = new Promise((_resolve, reject) => { + rejectClose = () => reject(new Error('close canceled')) + }) + + void result.catch(() => undefined) + + return result + }) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'delayed-close-test', kind: 'close' }) + + act(() => rejectClose()) + + await waitFor(() => expect($treeFocusRequest.get()).toBeNull()) + expect(window.document.activeElement).toBe(close) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('restores the source tab when a rejected close leaves focus on the document body', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + + let rejectClose = () => {} + act(() => { + registerPaneCloser('delayed-close-test', () => { + const result = new Promise((_resolve, reject) => { + rejectClose = () => reject(new Error('close canceled')) + }) + + void result.catch(() => undefined) + + return result + }) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'delayed-close-test', kind: 'close' }) + + act(() => { + close.blur() + rejectClose() + }) + + await waitFor(() => { + expect($treeFocusRequest.get()).toBeNull() + expect(window.document.activeElement).toBe(tabControl('delayed-close-test')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('clears recovery when a synchronous closer leaves its close control visible', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + act(() => registerPaneCloser('delayed-close-test', () => undefined)) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => expect($treeFocusRequest.get()).toBeNull()) + expect(window.document.activeElement).toBe(close) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('clears recovery when a deferred closer settles without removing its close control', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + act(() => registerPaneCloser('delayed-close-test', () => Promise.resolve())) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => expect($treeFocusRequest.get()).toBeNull()) + expect(window.document.activeElement).toBe(close) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('clears a pending deferred-close request when its layout root unmounts', () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + act(() => registerPaneCloser('delayed-close-test', () => new Promise(() => undefined))) + const view = render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'delayed-close-test', kind: 'close' }) + + view.unmount() + + expect($treeFocusRequest.get()).toBeNull() + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('recovers focus after a registered closer hides a lone tab group', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + act(() => { + registerPaneCloser('delayed-close-test', () => setTreePaneHidden('delayed-close-test', true)) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect($hiddenTreePanes.get().has('delayed-close-test')).toBe(true) + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('recovers focus after a registered closer collapses the source side', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['side-close-test', 'side-close-sibling'], { active: 'side-close-test', id: 'grp-side' }) + ]) + ) + act(() => { + registerPaneCloser('side-close-test', () => setTreeSideCollapsed('right', true)) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="side-close-test"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect($collapsedTreeSides.get().has('right')).toBe(true) + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => registerPaneCloser('side-close-test')) + } + }) + + it('skips a selected tab hidden in a collapsed side when recovering focus', async () => { + declareDefaultTree( + split('column', [ + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['side-close-test', 'side-close-sibling'], { active: 'side-close-test', id: 'grp-side' }) + ]), + group(['delayed-close-test'], { active: 'delayed-close-test', id: 'grp-tools' }) + ]) + ) + act(() => { + registerPaneCloser('delayed-close-test', () => dismissTreePane('delayed-close-test')) + setTreeSideCollapsed('right', true) + }) + render() + + try { + const hiddenSelected = tabControl('side-close-test')! + const close = window.document.querySelector('[data-tree-tab="delayed-close-test"] [data-pane-tab-close="true"]')! + expect(hiddenSelected.getAttribute('aria-selected')).toBe('true') + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => registerPaneCloser('delayed-close-test')) + } + }) + + it('moves focus to an application fallback when a close hides the remaining lone tab', async () => { + declareDefaultTree( + split('row', [ + group(['workspace'], { active: 'workspace', id: 'grp-main' }), + group(['plain-a', 'plain-b'], { active: 'plain-a', id: 'grp-tools' }) + ]) + ) + act(() => { + registerPaneCloser('plain-a', () => dismissTreePane('plain-a')) + registerPaneCloser('plain-b', () => dismissTreePane('plain-b')) + }) + render() + + try { + const close = window.document.querySelector('[data-tree-tab="plain-a"] [data-pane-tab-close="true"]')! + act(() => close.focus()) + act(() => fireEvent.click(close)) + + await waitFor(() => { + expect(findGroup($layoutTree.get()!, 'grp-tools')?.panes).toEqual(['plain-b']) + expect(tabControl('plain-b')).toBeNull() + expect(window.document.activeElement).toBe(window.document.querySelector('[data-slot="composer-rich-input"]')) + }) + } finally { + act(() => { + registerPaneCloser('plain-a') + registerPaneCloser('plain-b') + }) + } + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 3f237d1290b28..86fd8ac657aca 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -10,7 +10,7 @@ */ import { useStore } from '@nanostores/react' -import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' @@ -54,6 +54,7 @@ import { isSessionStripPane, noteActiveTreeGroup, reloadTreePane, + resolveRenderedTreeGroupSelection, restoreTreePane, SESSION_TILE_DRAG, setTreeGroupHeaderHidden, @@ -68,12 +69,24 @@ import { selectTabRange, toggleTabSelected } from '../tab-selection' +import { $treeFocusRequest, requestTreeFocusAfterRestore, runTreeCloseWithFocusRecovery } from '../tree-focus' import { type DoubleTapContext, startPaneDrag } from './drag-session' import { forceLoneHeaderForPanes } from './lone-header' import { useActiveTabVisible } from './tab-strip-scroll' import { paneChrome } from './track-model' +const HORIZONTAL_TAB_KEY_DELTAS: Readonly> = { ArrowLeft: -1, ArrowRight: 1 } +const VERTICAL_TAB_KEY_DELTAS: Readonly> = { ArrowDown: 1, ArrowUp: -1 } + +function paneTabId(groupId: string, paneId: string): string { + return `tree-tab-${encodeURIComponent(groupId)}-${encodeURIComponent(paneId)}` +} + +function panePanelId(groupId: string, paneId: string): string { + return `tree-panel-${encodeURIComponent(groupId)}-${encodeURIComponent(paneId)}` +} + /** Right-click zone menu: the tab verbs (close this / others / to the right / * all) plus the strip's own chrome toggles. Same items and icons as a session * tab's menu, so every tab in a strip answers a right-click the same way — @@ -86,6 +99,7 @@ function ZoneMenu({ headerHidden, minimized, nodeId, + onToggleMinimized, targetPane }: { children: ReactNode @@ -98,6 +112,7 @@ function ZoneMenu({ headerHidden?: boolean minimized?: boolean nodeId: string + onToggleMinimized: () => void /** The right-clicked chip (else the active pane) — what the close-others / * to-the-right / all verbs measure from. Called when the menu RENDERS, not * on every zone re-render: resolving the siblings reads the layout tree, @@ -124,22 +139,39 @@ function ZoneMenu({ {paneTabCloseItems(kit, { counts: treeTabCloseTargets(targetId), - onClose: paneId !== undefined ? () => closeTabPane(paneId) : undefined, - onCloseAll: () => closeAllTreeTabs(targetId), - onCloseOthers: () => closeOtherTreeTabs(targetId), - onCloseToRight: () => closeTreeTabsToRight(targetId) + onClose: + paneId !== undefined + ? () => { + runTreeCloseWithFocusRecovery(paneId, () => closeTabPane(paneId), nodeId) + } + : undefined, + onCloseAll: () => { + runTreeCloseWithFocusRecovery(targetId, () => closeAllTreeTabs(targetId), nodeId) + }, + onCloseOthers: () => { + runTreeCloseWithFocusRecovery(targetId, () => closeOtherTreeTabs(targetId), nodeId) + }, + onCloseToRight: () => { + runTreeCloseWithFocusRecovery(targetId, () => closeTreeTabsToRight(targetId), nodeId) + } })} {renderActionItem(kit, { icon: headerHidden ? 'eye' : 'eye-closed', label: headerHidden ? t.zones.showHeader : t.zones.hideHeader, - onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden) + onSelect: () => { + if (!headerHidden) { + requestTreeFocusAfterRestore(nodeId, targetId) + } + + setTreeGroupHeaderHidden(nodeId, !headerHidden) + } })} {minimizable && renderActionItem(kit, { icon: minimized ? 'chevron-down' : 'chevron-up', label: minimized ? t.zones.restore : t.zones.minimize, - onSelect: () => setTreeGroupMinimized(nodeId, !minimized) + onSelect: onToggleMinimized })} ) @@ -175,6 +207,7 @@ export function TreeGroup({ // missing on an inactive tile tab whose zone-active was the uncloseable // workspace). const [menuPane, setMenuPane] = useState(undefined) + const [pendingCloseFocus, setPendingCloseFocus] = useState(null) const panes = useContributions('panes') // Coarse drag flag only (set once at drag start/end). The per-frame drop // HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny @@ -187,6 +220,7 @@ export function TreeGroup({ const narrow = useStore($narrowViewport) const newSessionTabAction = useStore($newSessionTabAction) const panesWithCloser = useStore($panesWithCloser) + const treeFocusRequest = useStore($treeFocusRequest) // Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but // the one holding it, so this subscription is quiet during normal use. const tabSelection = useStore($tabSelection) @@ -204,11 +238,13 @@ export function TreeGroup({ // shown one (render-side — the tree keeps `active`). // Edit mode forces toggle-hidden panes visible so they can be rearranged // (mirrors tree-split's paneGone) — restores itself on exit. - const paneShown = (id: string) => - Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + const { activeId: renderedActiveId, shown } = resolveRenderedTreeGroupSelection(node, { + editMode, + hiddenPanes, + narrow + }) - const shown = node.panes.filter(paneShown) - const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const activeId = renderedActiveId ?? node.active const active = paneFor(activeId) const isEmpty = node.panes.length === 0 @@ -271,12 +307,63 @@ export function TreeGroup({ tabCount: shown.length }) + const focusTabControl = useCallback((paneId: string) => { + const tab = Array.from(ref.current?.querySelectorAll('[data-tree-tab]') ?? []).find( + element => element.dataset.treeTab === paneId + ) + + tab?.querySelector('[data-pane-tab-control="true"]')?.focus({ preventScroll: true }) + }, []) + + const restoreMinimizedPane = (paneId: string) => { + // The focused minimized control disappears synchronously. Reserve the root + // handoff before the layout restore so automatic nested focus cannot win. + requestTreeFocusAfterRestore(node.id, paneId) + restoreTreePane(paneId) + } + + useEffect(() => { + if (!pendingCloseFocus) { + return + } + + const request = + treeFocusRequest?.kind === 'close' && treeFocusRequest.id === pendingCloseFocus.requestId ? treeFocusRequest : null + + // Another keyboard close superseded this one, or the root has already + // resolved it. Only the current request owns focus recovery. + if (!request) { + setPendingCloseFocus(null) + + return + } + + // A registered closer may show a confirmation first. Keep the intent until + // it settles, even if the pane disappears before the dialog closes. + if (request.status === 'pending' || shown.includes(pendingCloseFocus.paneId)) { + return + } + + setPendingCloseFocus(null) + + if (document.activeElement !== document.body) { + return + } + + const focusTarget = shown.includes(activeId) ? activeId : shown[0] + + if (focusTarget) { + focusTabControl(focusTarget) + } + }, [activeId, focusTabControl, pendingCloseFocus, shown, treeFocusRequest]) + // Drag handles preventDefault pointerdown (no native dblclick), so the // header + chips share a synthesized double-tap: restore if collapsed // (undoing the first tap's minimize toggle) and hide the chrome. const hideHeaderDoubleTap: DoubleTapContext = { key: `hide-header-${node.id}`, onDoubleTap: () => { + requestTreeFocusAfterRestore(node.id, activeId) setTreeGroupMinimized(node.id, false) setTreeGroupHeaderHidden(node.id, true) } @@ -304,9 +391,69 @@ export function TreeGroup({ // MAIN strands the whole app behind a strip. const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable) - // Middle-click / ⌘-click on a tab: one routing for every tab kind, the same - // one the zone menu's Close and ⌘W use. - const closeTab = (paneId: string) => closeTabPane(paneId) + const activateTab = (paneId: string) => { + clearTabSelection() + + if (node.minimized) { + restoreMinimizedPane(paneId) + + if (verticalCollapse) { + return + } + } + + activateTreePane(node.id, paneId) + } + + const onTabKeyDown = (event: React.KeyboardEvent, paneId: string) => { + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + + const index = shown.indexOf(paneId) + const delta = (verticalCollapse ? VERTICAL_TAB_KEY_DELTAS : HORIZONTAL_TAB_KEY_DELTAS)[event.key] + + const directDestinations: Readonly> = { + ' ': paneId, + End: shown.at(-1), + Enter: paneId, + Home: shown[0] + } + + const destination = + delta === undefined ? directDestinations[event.key] : shown[(index + delta + shown.length) % shown.length] + + if (!destination) { + return + } + + event.preventDefault() + event.stopPropagation() + + if (!verticalCollapse) { + focusTabControl(destination) + } + + activateTab(destination) + } + + // Every close gesture that can remove a pane enters the shared lifecycle. + // That includes middle/⌘ clicks, which never focus the close button and + // otherwise strand focus on body when they remove the currently focused tab. + const closeTab = (paneId: string) => { + if (!paneChrome(paneFor(paneId)).uncloseable) { + const { request } = runTreeCloseWithFocusRecovery(paneId, () => closeTabPane(paneId), node.id) + + if (request) { + setPendingCloseFocus({ paneId, requestId: request.id }) + } + + return + } + + setPendingCloseFocus(null) + closeTabPane(paneId) + } // A pane whose store owns Close keeps the gesture even when the pane itself // is uncloseable — the workspace tab empties to a fresh draft rather than @@ -318,7 +465,15 @@ export function TreeGroup({ // Collapse/restore a tool panel (or plain minimize elsewhere) — the header // chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful. - const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId)) + const toggleCollapse = () => { + if (node.minimized) { + restoreMinimizedPane(activeId) + + return + } + + runTreeCloseWithFocusRecovery(activeId, () => collapseTreePane(activeId), node.id) + } // Same menu on the header strip and the edit veil — one prop bag. const zoneMenu = { @@ -327,6 +482,7 @@ export function TreeGroup({ minimizable, minimized: node.minimized, nodeId: node.id, + onToggleMinimized: toggleCollapse, targetPane } @@ -373,10 +529,11 @@ export function TreeGroup({ // Strip line faces the content the zone collapsed away from. railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT )} - onClick={() => restoreTreePane(activeId)} + onClick={() => restoreMinimizedPane(activeId)} title={t.zones.restore} >
@@ -385,18 +542,22 @@ export function TreeGroup({ return ( { event.stopPropagation() - restoreTreePane(paneId) + activateTab(paneId) }} onClose={closeable ? () => closeTab(paneId) : undefined} + onKeyDown={event => onTabKeyDown(event, paneId)} role="tab" side={railSide} + tabIndex={paneId === activeId ? 0 : -1} vertical > {tabLabel(paneId)} @@ -449,6 +610,7 @@ export function TreeGroup({ > {shown.map(paneId => { const isActive = paneId === activeId && !node.minimized + const isAriaSelected = paneId === activeId const chrome = paneChrome(paneFor(paneId)) const closeable = closeableTab(paneId) const title = paneFor(paneId)?.title ?? paneId @@ -457,10 +619,13 @@ export function TreeGroup({ const tab = ( closeTab(paneId) : undefined} + onKeyDown={event => onTabKeyDown(event, paneId)} onPointerDown={e => { // Chrome's tab-selection grammar, ahead of activate/drag: // Shift-click ranges from the anchor, ⌥-click (Ctrl-click @@ -489,15 +654,7 @@ export function TreeGroup({ // the active tab made double-click a minimize/restore/hide // lottery. A plain click also collapses any multi-tab // selection back to the one tab (Chrome semantics). - const onTap = () => { - clearTabSelection() - - if (node.minimized) { - restoreTreePane(paneId) - } - - activateTreePane(node.id, paneId) - } + const onTap = () => activateTab(paneId) // Claim the press so the STRIP's own pane-drag handler // (parent onPointerDown) can't also fire. startPaneDrag @@ -546,6 +703,7 @@ export function TreeGroup({ role="tab" selected={isSelected} style={{ cursor: 'grab' }} + tabIndex={paneId === activeId ? 0 : -1} > {chrome.tabLead ? ( {chrome.tabLead()} @@ -607,46 +765,80 @@ export function TreeGroup({
) : ( - keptPanes.map(paneId => { - const pane = paneFor(paneId) - const isActive = paneId === activeId - - return ( -
- {pane?.render ? ( - // Visibility flows to the pane so a kept-alive chat surface - // can gate its hot (per-token) subscriptions while hidden; - // the group id identifies the ZONE it lives in, for state - // that is per-zone rather than per-tab (composer pop-out). - // The reload epoch keys the CONTENT, not this layer: a - // Reload remounts the contribution (effects re-run, state - // resets) while the layer — and every other tab — stays. - - - - - - - - ) : ( - isActive && ( -
- {t.zones.missingPane(paneId)} -
- ) - )} -
- ) - }) + <> + {keptPanes.map(paneId => { + const pane = paneFor(paneId) + const isActive = paneId === activeId + const hasTabPanel = headerVisible + + return ( +
+ {pane?.render ? ( + // Visibility flows to the pane so a kept-alive chat surface + // can gate its hot (per-token) subscriptions while hidden; + // the group id identifies the ZONE it lives in, for state + // that is per-zone rather than per-tab (composer pop-out). + // The reload epoch keys the CONTENT, not this layer: a + // Reload remounts the contribution (effects re-run, state + // resets) while the layer — and every other tab — stays. + + + + + + + + ) : ( + isActive && ( +
+ {t.zones.missingPane(paneId)} +
+ ) + )} +
+ ) + })} + {headerVisible && + shown + .filter(paneId => !keptPanes.includes(paneId)) + .map(paneId => ( + )} + {/* A minimized strip still exposes tabs, so keep a hidden panel target + for every aria-controls relationship. Content stays unmounted while + minimized; these semantic placeholders vanish on restore. */} + {node.minimized && + shown.map(paneId => ( +