From 5eb9d2c7353680345a6a8d43e92fdd41825b9793 Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Sat, 8 Aug 2026 17:11:40 -0400 Subject: [PATCH 01/10] test(desktop): cover visible pane tab close control --- apps/desktop/src/components/ui/pane-tab.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index 5b340a9fb1717..32e12cbbc9f3c 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -92,3 +92,15 @@ describe('PaneTab close gestures', () => { expect(onPointerDown).toHaveBeenCalledTimes(1) }) }) + +describe('PaneTab close button', () => { + it('renders an accessible close button for a closeable horizontal tab', () => { + render( + + tab + + ) + + expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy() + }) +}) From 3f72c32f579208cb300b9a6f48ef51d1cf47739d Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 22 Jul 2026 10:00:55 -0400 Subject: [PATCH 02/10] feat(desktop): hover X close button on zone tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a hover-to-close X button to PaneTab (the fancy-zones tab shell). The button slot is always reserved inline (shrink-0) so the tab width stays stable whether the X is visible or not — no layout shift on hover. Visible on group-hover/tab, hidden by default via opacity transition. The dirty dot yields to the X when both are present (closeable + dirty): the X wins on hover, the dot shows otherwise. Vertical tabs skip the X (writing-mode:vertical-rl makes an inline button awkward) and keep the absolute-positioned dirty dot. Pointerdown on the X is stopped so the tab's drag/activate handlers never fire — the X is a leaf close action, not a drag start. --- .../src/components/ui/pane-tab.test.tsx | 38 +++++++++++++++++++ apps/desktop/src/components/ui/pane-tab.tsx | 35 +++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index 32e12cbbc9f3c..3d748c9246fae 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -103,4 +103,42 @@ describe('PaneTab close button', () => { expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy() }) + + it('clicking the close button calls onClose and stops propagation', () => { + const onClose = vi.fn() + const onTabPointerDown = vi.fn() + render( + + tab + + ) + + const closeBtn = screen.getByRole('button', { name: 'Close tab' }) + fireEvent.pointerDown(closeBtn) + fireEvent.click(closeBtn) + expect(onClose).toHaveBeenCalledTimes(1) + // The tab's own pointerdown handler must NOT fire — the X is a leaf action. + expect(onTabPointerDown).not.toHaveBeenCalled() + }) + + it('does not render a close button on vertical tabs', () => { + const onClose = vi.fn() + render( + + tab + + ) + + expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull() + }) + + it('does not render a close button without onClose', () => { + render( + + tab + + ) + + expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull() + }) }) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 9234aebd55eb8..7276b13524f03 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { translateNow } from '@/i18n' import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' @@ -44,8 +45,8 @@ const TAB_SELECTED = interface PaneTabProps extends React.ComponentProps<'div'> { active?: boolean dirty?: boolean - /** Close gesture, no hover X (too easy to hit on small tabs): middle-click, - * or ⌘-click as the trackpad-friendly Mac equivalent. */ + /** Close gesture: hover X on horizontal tabs, plus middle-click and ⌘-click + * (the trackpad-friendly Mac equivalent). */ onClose?: () => void /** Part of a multi-tab selection (⌥/Ctrl-click, Shift-click) — an accent * wash marks every tab that a drag would carry, Chrome-style. */ @@ -140,7 +141,35 @@ export const PaneTab = React.forwardRef(function P {...props} > {children} - {dirty && ( + {onClose && !vertical && ( + + )} + {dirty && !(onClose && !vertical) && ( Date: Sat, 8 Aug 2026 17:31:52 -0400 Subject: [PATCH 03/10] fix(desktop): make pane tab close controls accessible --- .../src/components/ui/pane-tab.test.tsx | 38 ++++++++++++++++++- apps/desktop/src/components/ui/pane-tab.tsx | 37 +++++++++++------- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 8 files changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index 3d748c9246fae..73a8678fa0665 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -1,9 +1,14 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRuntimeI18nLocale } from '@/i18n/runtime' + import { PaneTab, PaneTabLabel } from './pane-tab' -afterEach(cleanup) +afterEach(() => { + cleanup() + setRuntimeI18nLocale('en') +}) describe('PaneTab close gestures', () => { it('middle-click closes — pointer events only, no auxclick', () => { @@ -104,6 +109,37 @@ describe('PaneTab close button', () => { expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy() }) + it('uses the active locale for the close button label', () => { + setRuntimeI18nLocale('zh') + render( + + tab + + ) + + expect(screen.getByRole('button', { name: '关闭标签' })).toBeTruthy() + }) + + it('reveals the close button when keyboard focus reaches it', () => { + render( + + tab + + ) + + expect(screen.getByRole('button', { name: 'Close tab' }).className).toContain('focus-visible:opacity-100') + }) + + it('keeps the dirty indicator until hover reveals the close glyph', () => { + const { container } = render( + + tab + + ) + + expect(container.querySelector('[data-slot="pane-tab-dirty-indicator"]')).toBeTruthy() + }) + it('clicking the close button calls onClose and stops propagation', () => { const onClose = vi.fn() const onTabPointerDown = vi.fn() diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 7276b13524f03..fa670b7938ea0 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -142,17 +142,9 @@ export const PaneTab = React.forwardRef(function P > {children} {onClose && !vertical && ( - + {dirty && ( + + + + )} + + )} - {dirty && !(onClose && !vertical) && ( + {dirty && (!onClose || vertical) && ( diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 61452064cf1ab..579c44178eaee 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2246,6 +2246,7 @@ export const ar = defineLocale({ 'هذه المحادثة ما زالت تعمل (أو تنتظر إدخالك). إغلاق التبويب يخفيها فقط — ستحتفظ الجلسة بتقدمها ويمكن إعادة فتحها من الشريط الجانبي.', closeRunningConfirm: 'إغلاق التبويب', reload: 'إعادة التحميل', + closeTab: 'إغلاق التبويب', closeOthers: 'إغلاق الأخرى', closeToRight: 'إغلاق ما على اليمين', closeAll: 'إغلاق الكل', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b0dba1f0e9d51..3c4f3a0a1617d 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2700,6 +2700,7 @@ export const en: Translations = { 'This chat is still working (or waiting on your input). Closing the tab hides it — the session keeps its progress and can be reopened from the sidebar.', closeRunningConfirm: 'Close tab', reload: 'Reload', + closeTab: 'Close tab', closeOthers: 'Close others', closeToRight: 'Close to the right', closeAll: 'Close all', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 740fbb2bb47b9..a8ac71429c4ef 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2506,6 +2506,7 @@ export const ja = defineLocale({ minimize: '最小化', restore: '復元', reload: '再読み込み', + closeTab: 'タブを閉じる', closeOthers: '他を閉じる', closeToRight: '右側を閉じる', closeAll: 'すべて閉じる', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d611086143091..c8d19f0b3a3a4 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2293,6 +2293,7 @@ export interface Translations { closeRunningBody: string closeRunningConfirm: string reload: string + closeTab: string closeOthers: string closeToRight: string closeAll: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index fb84840259e34..3a32615b6c16c 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2426,6 +2426,7 @@ export const zhHant = defineLocale({ minimize: '最小化', restore: '還原', reload: '重新載入', + closeTab: '關閉分頁', closeOthers: '關閉其他', closeToRight: '關閉右側', closeAll: '全部關閉', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 71eb545f70f01..7e147d775f56f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2877,6 +2877,7 @@ export const zh: Translations = { closeRunningBody: '此对话仍在运行(或正在等待你的输入)。关闭标签只会隐藏它——会话将保留进度,可从侧边栏重新打开。', closeRunningConfirm: '关闭标签', reload: '重新加载', + closeTab: '关闭标签', closeOthers: '关闭其他', closeToRight: '关闭右侧', closeAll: '全部关闭', From bd447669525c90dba21d0e0cc703677f7ba72d47 Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Sat, 8 Aug 2026 18:37:52 -0400 Subject: [PATCH 04/10] test(desktop): cover pane tab close interaction --- apps/desktop/e2e/pane-tab-close.spec.ts | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/desktop/e2e/pane-tab-close.spec.ts 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..c908ba138836a --- /dev/null +++ b/apps/desktop/e2e/pane-tab-close.spec.ts @@ -0,0 +1,74 @@ +/** + * 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 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 page.waitForTimeout(300) + await composer.click() + await expect(composer).toBeFocused() + await composer.type(text, { delay: 20 }) + await page.keyboard.press('Enter') + + const activeTranscript = page.locator('[data-slot="aui_thread-viewport"]:visible').last() + 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 on hover and closes only that tab', 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 activeTab = page + .locator('[role="tab"][aria-selected="true"]:visible') + .filter({ has: page.locator('button[aria-label="Close tab"]') }) + + await expect(activeTab).toHaveCount(1) + + const tabList = activeTab.locator('xpath=ancestor::*[@role="tablist"][1]') + const closeButtons = tabList.locator('button[aria-label="Close tab"]') + await expect.poll(() => closeButtons.count()).toBeGreaterThan(1) + const openTabCount = await closeButtons.count() + + const closeButton = activeTab.locator('button[aria-label="Close tab"]') + const closeIcon = closeButton.locator('[data-slot="pane-tab-close-icon"]') + await expect(closeIcon).toHaveCSS('opacity', '0') + + await activeTab.hover() + await expect(closeIcon).toHaveCSS('opacity', '1') + await page.screenshot({ path: testInfo.outputPath('tab-close-hover.png') }) + + await closeButton.click() + await expect(closeButtons).toHaveCount(openTabCount - 1) +}) From 092639e0856f7b7bfea0078082f8c55e8942c1a2 Mon Sep 17 00:00:00 2001 From: Nicky Molina Date: Sat, 8 Aug 2026 19:59:04 -0400 Subject: [PATCH 05/10] fix(desktop): separate tab and close control semantics --- apps/desktop/e2e/pane-tab-close.spec.ts | 58 ++++-- .../src/components/ui/pane-tab.test.tsx | 117 +++++++++++- apps/desktop/src/components/ui/pane-tab.tsx | 167 +++++++++++++----- 3 files changed, 278 insertions(+), 64 deletions(-) diff --git a/apps/desktop/e2e/pane-tab-close.spec.ts b/apps/desktop/e2e/pane-tab-close.spec.ts index c908ba138836a..d5259941fa839 100644 --- a/apps/desktop/e2e/pane-tab-close.spec.ts +++ b/apps/desktop/e2e/pane-tab-close.spec.ts @@ -12,16 +12,20 @@ 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 page.waitForTimeout(300) await composer.click() await expect(composer).toBeFocused() await composer.type(text, { delay: 20 }) await page.keyboard.press('Enter') - const activeTranscript = page.locator('[data-slot="aui_thread-viewport"]:visible').last() await expect(activeTranscript).toContainText(text, { timeout: 15_000 }) await expect(activeTranscript).toContainText(REPLY, { timeout: 60_000 }) } @@ -36,7 +40,9 @@ test.afterAll(async () => { fixture = null }) -test('reveals a close control on hover and closes only that tab', async ({ playwright: _playwright }, testInfo) => { +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') @@ -50,25 +56,49 @@ test('reveals a close control on hover and closes only that tab', async ({ playw // gesture into a native context click before React receives it. await sessionRows.last().dispatchEvent('click', { ctrlKey: true }) - const activeTab = page - .locator('[role="tab"][aria-selected="true"]:visible') - .filter({ has: page.locator('button[aria-label="Close tab"]') }) + const closeButtons = page.locator('[data-tree-tab] > button[aria-label="Close tab"]') + await expect.poll(() => closeButtons.count()).toBeGreaterThan(1) - await expect(activeTab).toHaveCount(1) + const tabList = closeButtons.first().locator('xpath=ancestor::*[@role="tablist"][1]') + const tabItems = tabList.locator('[data-tree-tab]') + const selectedTab = tabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') - const tabList = activeTab.locator('xpath=ancestor::*[@role="tablist"][1]') - const closeButtons = tabList.locator('button[aria-label="Close tab"]') - await expect.poll(() => closeButtons.count()).toBeGreaterThan(1) - const openTabCount = await closeButtons.count() + 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) + + 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 = activeTab.locator('button[aria-label="Close tab"]') + 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') - await activeTab.hover() + await inactiveTabItem.hover() await expect(closeIcon).toHaveCSS('opacity', '1') await page.screenshot({ path: testInfo.outputPath('tab-close-hover.png') }) await closeButton.click() - await expect(closeButtons).toHaveCount(openTabCount - 1) + await expect + .poll(() => tabItems.evaluateAll(items => items.map(item => item.getAttribute('data-tree-tab')))) + .not.toContain(inactiveTabId) + + const remainingSelectedTab = tabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') + await expect(remainingSelectedTab).toHaveCount(1) + await expect(remainingSelectedTab.locator('xpath=..')).toHaveAttribute('data-tree-tab', selectedTabId) + await expect(activeTranscript).toContainText('first close-button session') }) diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index 73a8678fa0665..76c19f4626d8f 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -1,4 +1,5 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { setRuntimeI18nLocale } from '@/i18n/runtime' @@ -99,14 +100,44 @@ describe('PaneTab close gestures', () => { }) describe('PaneTab close button', () => { - it('renders an accessible close button for a closeable horizontal tab', () => { + it('keeps the tab and its close button as sibling controls in the tablist', () => { + const visualWrapperRef = createRef() + const onKeyDown = vi.fn() + render( - - tab - +
+ + tab + +
) - expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy() + const tab = screen.getByRole('tab', { name: 'Messages' }) + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + + expect(tab.contains(closeButton)).toBe(false) + expect(tab.parentElement).toBe(closeButton.parentElement) + expect(tab.parentElement?.getAttribute('role')).toBe('presentation') + expect(tab.id).toBe('tab-a') + expect(tab.getAttribute('aria-controls')).toBe('panel-a') + expect(tab.getAttribute('aria-selected')).toBe('false') + expect(visualWrapperRef.current).toBe(tab.parentElement) + expect(visualWrapperRef.current?.dataset.treeTab).toBe('pane-a') + expect(visualWrapperRef.current?.style.cursor).toBe('grab') + + fireEvent.keyDown(tab, { key: 'Enter' }) + expect(onKeyDown).toHaveBeenCalledTimes(1) }) it('uses the active locale for the close button label', () => { @@ -127,17 +158,44 @@ describe('PaneTab close button', () => { ) - expect(screen.getByRole('button', { name: 'Close tab' }).className).toContain('focus-visible:opacity-100') + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + const closeIcon = closeButton.querySelector('[data-slot="pane-tab-close-icon"]')! + + expect(closeIcon.style.opacity).toBe('0') + + act(() => closeButton.focus()) + + expect(closeButton.ownerDocument.activeElement).toBe(closeButton) + expect(closeIcon.style.opacity).toBe('1') }) - it('keeps the dirty indicator until hover reveals the close glyph', () => { + it('swaps the dirty dot for the close glyph on hover and keyboard focus', () => { const { container } = render( tab ) - expect(container.querySelector('[data-slot="pane-tab-dirty-indicator"]')).toBeTruthy() + const dirtyIndicator = container.querySelector('[data-slot="pane-tab-dirty-indicator"]')! + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + const closeIcon = closeButton.querySelector('[data-slot="pane-tab-close-icon"]')! + const visualWrapper = closeButton.parentElement! + + expect(dirtyIndicator.style.opacity).toBe('1') + expect(closeIcon.style.opacity).toBe('0') + + fireEvent.mouseEnter(visualWrapper) + expect(dirtyIndicator.style.opacity).toBe('0') + expect(closeIcon.style.opacity).toBe('1') + + fireEvent.mouseLeave(visualWrapper) + expect(dirtyIndicator.style.opacity).toBe('1') + expect(closeIcon.style.opacity).toBe('0') + + act(() => closeButton.focus()) + expect(closeButton.ownerDocument.activeElement).toBe(closeButton) + expect(dirtyIndicator.style.opacity).toBe('0') + expect(closeIcon.style.opacity).toBe('1') }) it('clicking the close button calls onClose and stops propagation', () => { @@ -157,6 +215,47 @@ describe('PaneTab close button', () => { expect(onTabPointerDown).not.toHaveBeenCalled() }) + it('middle-clicking the close-control area closes once without reaching the tab strip', () => { + const onClose = vi.fn() + const onStripPointerDown = vi.fn() + render( +
+ + tab + +
+ ) + + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + fireEvent.pointerDown(closeButton, { button: 1 }) + fireEvent.mouseDown(closeButton, { button: 1 }) + fireEvent.pointerUp(closeButton, { button: 1 }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onStripPointerDown).not.toHaveBeenCalled() + }) + + it('leaves right-click and context-menu events over the close control alone', () => { + const onClose = vi.fn() + const onStripPointerDown = vi.fn() + const onContextMenu = vi.fn() + render( +
+ + tab + +
+ ) + + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + fireEvent.pointerDown(closeButton, { button: 2 }) + fireEvent.contextMenu(closeButton) + + expect(onClose).not.toHaveBeenCalled() + expect(onStripPointerDown).toHaveBeenCalledTimes(1) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + it('does not render a close button on vertical tabs', () => { const onClose = vi.fn() render( diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index fa670b7938ea0..d865569e00c27 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -44,6 +44,8 @@ const TAB_SELECTED = interface PaneTabProps extends React.ComponentProps<'div'> { active?: boolean + /** Stable pane identity belongs to the visual wrapper used by drag/scroll logic. */ + 'data-tree-tab'?: string dirty?: boolean /** Close gesture: hover X on horizontal tabs, plus middle-click and ⌘-click * (the trackpad-friendly Mac equivalent). */ @@ -69,15 +71,23 @@ export const PaneTab = React.forwardRef(function P active = false, dirty = false, onClose, + onClick, + onMouseEnter, + onMouseLeave, onMouseDown, onPointerDown, onPointerUp, onClickCapture, + role, selected = false, + tabIndex, vertical = false, side = 'left', children, className, + style, + 'aria-selected': ariaSelected, + 'data-tree-tab': treeTabId, ...props }, ref @@ -85,7 +95,12 @@ export const PaneTab = React.forwardRef(function P // Vertical rails only. Horizontal tabs draw no bottom border — the strip owns // that rule, and a per-tab border stacked a second translucent line over it. const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined + const canShowClose = Boolean(onClose && !vertical) const middle = middleClickHandlers(onClose) + const closeMiddle = middleClickHandlers(onClose) + const [closeHovered, setCloseHovered] = React.useState(false) + const [closeFocused, setCloseFocused] = React.useState(false) + const closeVisible = closeHovered || closeFocused return (
(function P )} data-active={active} data-selected={selected || undefined} + data-tree-tab={treeTabId} data-vertical={vertical || undefined} - onClickCapture={event => { - // Sites whose tab activates on the label's own onClick (the preview - // rail) fire it AFTER our pointerdown close — swallow that stray click - // in the capture phase so it can't re-select the just-closed tab. - if (onClose && isMetaClose(event)) { - event.preventDefault() - event.stopPropagation() + onMouseEnter={event => { + if (canShowClose) { + setCloseHovered(true) } - onClickCapture?.(event) + onMouseEnter?.(event) }} - onMouseDown={event => { - middle.onMouseDown(event) - onMouseDown?.(event) - }} - onPointerDown={event => { - middle.onPointerDown(event) - - // ⌘-click closes. Preempt here — the tab strips activate/drag on - // pointerdown (drag-session onTap), so we must claim the press before - // the shell's own handler starts a drag, and skip it entirely. - if (onClose && isMetaClose(event)) { - event.preventDefault() - event.stopPropagation() - onClose() - - return + onMouseLeave={event => { + if (canShowClose) { + setCloseHovered(false) } - onPointerDown?.(event) - }} - onPointerUp={event => { - middle.onPointerUp(event) - onPointerUp?.(event) + onMouseLeave?.(event) }} ref={ref} - {...props} + role={role ? 'presentation' : undefined} + style={style} > - {children} - {onClose && !vertical && ( +
{ + // Sites whose tab activates on the label's own onClick (the preview + // rail) fire it AFTER our pointerdown close — swallow that stray click + // in the capture phase so it can't re-select the just-closed tab. + if (onClose && isMetaClose(event)) { + event.preventDefault() + event.stopPropagation() + } + + onClickCapture?.(event) + }} + onMouseDown={event => { + middle.onMouseDown(event) + onMouseDown?.(event) + }} + onPointerDown={event => { + middle.onPointerDown(event) + + // ⌘-click closes. Preempt here — the tab strips activate/drag on + // pointerdown (drag-session onTap), so we must claim the press before + // the shell's own handler starts a drag, and skip it entirely. + if (onClose && isMetaClose(event)) { + event.preventDefault() + event.stopPropagation() + onClose() + + return + } + + onPointerDown?.(event) + }} + onPointerUp={event => { + middle.onPointerUp(event) + onPointerUp?.(event) + }} + role={role} + tabIndex={tabIndex} + {...props} + > + {children} +
+ {canShowClose && ( )} - {dirty && (!onClose || vertical) && ( + {dirty && !canShowClose && ( Date: Mon, 10 Aug 2026 03:58:05 -0400 Subject: [PATCH 06/10] fix(desktop): preserve close gestures across locale changes --- apps/desktop/e2e/pane-tab-close.spec.ts | 2 + .../src/components/ui/pane-tab.test.tsx | 67 ++++++++++++++++--- apps/desktop/src/components/ui/pane-tab.tsx | 23 ++++++- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/apps/desktop/e2e/pane-tab-close.spec.ts b/apps/desktop/e2e/pane-tab-close.spec.ts index d5259941fa839..cb29d85c36d7a 100644 --- a/apps/desktop/e2e/pane-tab-close.spec.ts +++ b/apps/desktop/e2e/pane-tab-close.spec.ts @@ -61,6 +61,7 @@ test('reveals a close control and closes an identified inactive tab without chan 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 @@ -96,6 +97,7 @@ test('reveals a close control and closes an identified inactive tab without chan await expect .poll(() => tabItems.evaluateAll(items => items.map(item => item.getAttribute('data-tree-tab')))) .not.toContain(inactiveTabId) + await expect(tabItems).toHaveCount(initialTabCount - 1) const remainingSelectedTab = tabList.locator('[data-tree-tab] > [role="tab"][aria-selected="true"]') await expect(remainingSelectedTab).toHaveCount(1) diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx index 76c19f4626d8f..8190d63bafd5e 100644 --- a/apps/desktop/src/components/ui/pane-tab.test.tsx +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -2,10 +2,25 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { setRuntimeI18nLocale } from '@/i18n/runtime' +import { I18nProvider, setRuntimeI18nLocale, useI18n } from '@/i18n' import { PaneTab, PaneTabLabel } from './pane-tab' +function LocaleSwitchingPaneTab() { + const { setLocale } = useI18n() + + return ( + <> + + + tab + + + ) +} + afterEach(() => { cleanup() setRuntimeI18nLocale('en') @@ -140,15 +155,19 @@ describe('PaneTab close button', () => { expect(onKeyDown).toHaveBeenCalledTimes(1) }) - it('uses the active locale for the close button label', () => { - setRuntimeI18nLocale('zh') + it('updates the close button label when the provider locale changes after mount', async () => { render( - - tab - + + + ) - expect(screen.getByRole('button', { name: '关闭标签' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Close tab' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Switch to Chinese' })) + + expect(await screen.findByRole('button', { name: '关闭标签' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull() }) it('reveals the close button when keyboard focus reaches it', () => { @@ -200,11 +219,15 @@ describe('PaneTab close button', () => { it('clicking the close button calls onClose and stops propagation', () => { const onClose = vi.fn() + const onStripClick = vi.fn() + const onStripPointerDown = vi.fn() const onTabPointerDown = vi.fn() render( - - tab - +
+ + tab + +
) const closeBtn = screen.getByRole('button', { name: 'Close tab' }) @@ -213,6 +236,8 @@ describe('PaneTab close button', () => { expect(onClose).toHaveBeenCalledTimes(1) // The tab's own pointerdown handler must NOT fire — the X is a leaf action. expect(onTabPointerDown).not.toHaveBeenCalled() + expect(onStripPointerDown).not.toHaveBeenCalled() + expect(onStripClick).not.toHaveBeenCalled() }) it('middle-clicking the close-control area closes once without reaching the tab strip', () => { @@ -235,6 +260,28 @@ describe('PaneTab close button', () => { expect(onStripPointerDown).not.toHaveBeenCalled() }) + it('⌘-clicking the close-control area closes once without reaching the tab strip', () => { + const onClose = vi.fn() + const onStripClick = vi.fn() + const onStripPointerDown = vi.fn() + render( +
+ + tab + +
+ ) + + const closeButton = screen.getByRole('button', { name: 'Close tab' }) + fireEvent.pointerDown(closeButton, { button: 0, metaKey: true }) + fireEvent.mouseDown(closeButton, { button: 0, metaKey: true }) + fireEvent.click(closeButton, { button: 0, metaKey: true }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onStripPointerDown).not.toHaveBeenCalled() + expect(onStripClick).not.toHaveBeenCalled() + }) + it('leaves right-click and context-menu events over the close control alone', () => { const onClose = vi.fn() const onStripPointerDown = vi.fn() diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index d865569e00c27..c5b5ed89e7ea0 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -4,7 +4,7 @@ import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' -import { translateNow } from '@/i18n' +import { translateNow, useI18n } from '@/i18n' import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' @@ -101,6 +101,7 @@ export const PaneTab = React.forwardRef(function P const [closeHovered, setCloseHovered] = React.useState(false) const [closeFocused, setCloseFocused] = React.useState(false) const closeVisible = closeHovered || closeFocused + const { t } = useI18n() return (
(function P
{canShowClose && ( + + ) +} + 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..b8c8939aa0542 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,18 @@ -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 { TerminalWorkspace } from './workspace' + +vi.mock('./instance', () => ({ + AgentTerminalInstance: ({ id }: { id: string }) =>
, + TerminalInstance: ({ id }: { id: string }) =>
+})) describe('TerminalRail', () => { beforeEach(() => { @@ -15,6 +23,7 @@ describe('TerminalRail', () => { afterEach(() => { cleanup() + $treeFocusRequest.set(null) $terminals.set([]) $activeTerminalId.set(null) }) @@ -46,4 +55,187 @@ describe('TerminalRail', () => { expect($activeTerminalId.get()).toBe('term-1') expect($terminals.get()).toHaveLength(1) }) + + it('recovers focus to the selected terminal tab after closing the focused tab', async () => { + $terminals.set([ + { auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }, + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + $activeTerminalId.set('term-2') + + render() + + const closingTab = screen.getByRole('tab', { name: '2. zsh' }) + act(() => { + closingTab.focus() + fireEvent.click(closingTab, { metaKey: true }) + }) + + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'terminal', status: 'settled' }) + const selectedTab = screen.getByRole('tab', { name: '1. PowerShell' }) + + await waitFor(() => expect(window.document.activeElement).toBe(selectedTab)) + expect(selectedTab.getAttribute('data-terminal-rail-focus-handoff')).toBe('') + }) + + it('routes ⌘W from a selected rail tab to the active terminal tab', () => { + $terminals.set([ + { auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }, + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + $activeTerminalId.set('term-2') + render() + + act(() => { + screen.getByRole('tab', { name: '2. zsh' }).focus() + + expect(closeActiveTab()).toBe(true) + }) + expect($terminals.get().map(term => term.id)).toEqual(['term-1']) + }) + + it('keeps rail focus when ⌘W closes a selected rail tab', async () => { + $terminals.set([ + { auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }, + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + $activeTerminalId.set('term-2') + render() + + act(() => { + screen.getByRole('tab', { name: '2. zsh' }).focus() + + expect(closeActiveTab()).toBe(true) + }) + + await waitFor(() => expect(window.document.activeElement).toBe(screen.getByRole('tab', { name: '1. PowerShell' }))) + }) + + it('releases the terminal focus handoff when focus leaves the selected tab', async () => { + $terminals.set([ + { auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }, + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + $activeTerminalId.set('term-2') + + render() + + const closingTab = screen.getByRole('tab', { name: '2. zsh' }) + act(() => { + closingTab.focus() + fireEvent.click(closingTab, { metaKey: true }) + }) + + const selectedTab = screen.getByRole('tab', { name: '1. PowerShell' }) + await waitFor(() => expect(selectedTab.getAttribute('data-terminal-rail-focus-handoff')).toBe('')) + act(() => screen.getByRole('button', { name: 'New terminal' }).focus()) + + await waitFor(() => expect(selectedTab.getAttribute('data-terminal-rail-focus-handoff')).toBeNull()) + }) + + it('routes middle-click close through the shared focus lifecycle', () => { + $terminals.set([...$terminals.get(), { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' }]) + + render() + + const tab = screen.getByRole('tab', { name: '2. zsh' }) + act(() => { + fireEvent.pointerDown(tab, { button: 1, pointerType: 'mouse' }) + fireEvent.mouseDown(tab, { button: 1 }) + fireEvent.pointerUp(tab, { button: 1, pointerType: 'mouse' }) + }) + + expect($terminals.get().map(term => term.id)).toEqual(['term-1']) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'terminal', status: 'settled' }) + }) + + it('routes context-menu bulk close through the shared focus lifecycle', async () => { + $terminals.set([...$terminals.get(), { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' }]) + + render() + + const tab = screen.getByRole('tab', { name: '2. zsh' }) + act(() => { + fireEvent.pointerDown(tab, { button: 2, pointerType: 'mouse' }) + fireEvent.contextMenu(tab, { button: 2 }) + }) + fireEvent.click(await screen.findByRole('menuitem', { name: /close all/i })) + + expect($terminals.get()).toEqual([]) + expect($treeFocusRequest.get()).toMatchObject({ closedPaneId: 'terminal', status: 'settled' }) + }) + + it('does not close a terminal while another pane owns deferred focus recovery', () => { + const pending = requestTreeFocusAfterClose('busy-session') + $terminals.set([...$terminals.get(), { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' }]) + + render() + + fireEvent.click(screen.getByRole('tab', { name: '2. zsh' }), { metaKey: true }) + + expect($terminals.get().map(term => term.id)).toEqual(['term-1', 'term-2']) + expect($treeFocusRequest.get()).toBe(pending) + }) + + it('uses vertical roving terminal tabs tied to matching tabpanels', async () => { + $terminals.set([ + { auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }, + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + + const view = render( + <> + + undefined} /> + + ) + + const tablist = screen.getByRole('tablist') + const firstTab = screen.getByRole('tab', { name: '1. PowerShell' }) + const secondTab = screen.getByRole('tab', { name: '2. zsh' }) + const firstPanelId = firstTab.getAttribute('aria-controls') + + const firstPanel = Array.from(view.baseElement.querySelectorAll('[role="tabpanel"]')).find( + panel => panel.id === firstPanelId + ) + + const secondPanel = Array.from(view.baseElement.querySelectorAll('[role="tabpanel"]')).find( + panel => panel.id === secondTab.getAttribute('aria-controls') + ) + + expect(tablist.getAttribute('aria-orientation')).toBe('vertical') + expect(firstTab.getAttribute('tabindex')).toBe('0') + expect(secondTab.getAttribute('tabindex')).toBe('-1') + expect(firstTab.id).not.toBe('') + expect(firstPanel?.getAttribute('role')).toBe('tabpanel') + expect(firstPanel?.getAttribute('aria-labelledby')).toBe(firstTab.id) + expect(firstPanel?.getAttribute('aria-hidden')).toBe('false') + expect(secondPanel?.getAttribute('aria-hidden')).toBe('true') + + act(() => { + firstTab.focus() + fireEvent.keyDown(firstTab, { key: 'ArrowDown' }) + }) + + expect($activeTerminalId.get()).toBe('term-2') + await waitFor(() => expect(window.document.activeElement).toBe(secondTab)) + expect(secondTab.getAttribute('tabindex')).toBe('0') + expect(firstTab.getAttribute('tabindex')).toBe('-1') + expect(firstPanel?.getAttribute('aria-hidden')).toBe('true') + expect(secondPanel?.getAttribute('aria-hidden')).toBe('false') + + act(() => { + fireEvent.keyDown(secondTab, { key: 'Home' }) + }) + + await waitFor(() => expect(window.document.activeElement).toBe(firstTab)) + expect($activeTerminalId.get()).toBe('term-1') + + act(() => { + fireEvent.keyDown(firstTab, { key: 'End' }) + }) + + await waitFor(() => expect(window.document.activeElement).toBe(secondTab)) + expect($activeTerminalId.get()).toBe('term-2') + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index d5e654b31b037..781810c66c2be 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -1,5 +1,7 @@ import { useStore } from '@nanostores/react' +import { type KeyboardEvent, useEffect, useRef, useState } from 'react' +import { togglePaneVisible } from '@/components/pane-shell/tree/store' import { Codicon } from '@/components/ui/codicon' import { ContextMenu, @@ -15,14 +17,18 @@ import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' import { $bindings } from '@/store/keybinds' -import { setTerminalTakeover } from '../store' - +import { + $terminalRailFocusHandoff, + clearTerminalRailFocusHandoff, + TERMINAL_RAIL_FOCUS_HANDOFF_ATTR +} from './focus-handoff' +import { terminalPanelId, terminalTabId } from './tab-aria' import { $activeTerminalId, $terminals, - closeAllTerminals, - closeOtherTerminals, - closeTerminal, + closeAllTerminalsWithFocusRecovery, + closeOtherTerminalsWithFocusRecovery, + closeTerminalWithFocusRecovery, createTerminal, selectTerminal, type TerminalEntry @@ -39,9 +45,123 @@ export function TerminalRail() { const terminals = useStore($terminals) const activeId = useStore($activeTerminalId) const bindings = useStore($bindings) + const railFocusHandoff = useStore($terminalRailFocusHandoff) + const railRef = useRef(null) + const focusSelectedTerminalRef = useRef(false) + const [focusHandoffTerminalId, setFocusHandoffTerminalId] = useState(null) const toggleHint = bindings['view.showTerminal']?.[0] const newHint = bindings['view.newTerminal']?.[0] + // eslint-disable-next-line no-restricted-syntax -- one-shot focus request, not a reactive value mirror. + useEffect(() => { + if (!focusSelectedTerminalRef.current && !railFocusHandoff) { + return + } + + // TerminalWorkspace activates xterm from a passive effect and its first + // resize frame. Two frames guarantee this runs after either sibling-effect + // order, so the terminal cannot steal focus back from the selected rail tab. + + let focusFrame = 0 + + const settleFrame = window.requestAnimationFrame(() => { + focusFrame = window.requestAnimationFrame(() => { + const selectedTab = railRef.current?.querySelector('[data-terminal-rail-tab][aria-selected="true"]') + + if (selectedTab) { + railRef.current?.querySelector(`[${TERMINAL_RAIL_FOCUS_HANDOFF_ATTR}]`)?.removeAttribute( + TERMINAL_RAIL_FOCUS_HANDOFF_ATTR + ) + // Apply the marker synchronously before focus. A delayed xterm mount + // can resolve at any later point and must yield while this tab owns + // the close/roving handoff. + selectedTab.setAttribute(TERMINAL_RAIL_FOCUS_HANDOFF_ATTR, '') + setFocusHandoffTerminalId(selectedTab.dataset.terminalRailTab ?? null) + selectedTab.focus() + } else { + setFocusHandoffTerminalId(null) + } + + focusSelectedTerminalRef.current = false + + if (railFocusHandoff) { + clearTerminalRailFocusHandoff() + } + }) + }) + + return () => { + window.cancelAnimationFrame(settleFrame) + window.cancelAnimationFrame(focusFrame) + } + }, [activeId, railFocusHandoff, terminals]) + + const requestSelectedTerminalFocus = () => { + focusSelectedTerminalRef.current = true + } + + const clearTerminalFocusHandoff = (id: string) => { + setFocusHandoffTerminalId(current => (current === id ? null : current)) + } + + const closeTerminal = (id: string) => { + requestSelectedTerminalFocus() + + if (!closeTerminalWithFocusRecovery(id)) { + focusSelectedTerminalRef.current = false + } + } + + const closeOtherTerminals = (id: string) => { + requestSelectedTerminalFocus() + + if (!closeOtherTerminalsWithFocusRecovery(id)) { + focusSelectedTerminalRef.current = false + } + } + + const closeAllTerminals = () => { + requestSelectedTerminalFocus() + + if (!closeAllTerminalsWithFocusRecovery()) { + focusSelectedTerminalRef.current = false + } + } + + const handleTabKeyDown = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + + const source = event.target instanceof Element ? event.target.closest('[data-terminal-rail-tab]') : null + const currentId = source?.dataset.terminalRailTab + const currentIndex = terminals.findIndex(term => term.id === currentId) + + if (currentIndex < 0) { + return + } + + const destination = + event.key === 'ArrowDown' + ? terminals[(currentIndex + 1) % terminals.length] + : event.key === 'ArrowUp' + ? terminals[(currentIndex - 1 + terminals.length) % terminals.length] + : event.key === 'Home' + ? terminals[0] + : event.key === 'End' + ? terminals.at(-1) + : undefined + + if (!destination) { + return + } + + event.preventDefault() + event.stopPropagation() + requestSelectedTerminalFocus() + selectTerminal(destination.id) + } + return (
-
    - {terminals.map((term, index) => ( - 1} - index={index} - key={term.id} - term={term} - toggleHint={toggleHint} - /> - ))} -
  • +
    +
      + {terminals.map((term, index) => ( + 1} + closeAllTerminals={closeAllTerminals} + closeOtherTerminals={closeOtherTerminals} + closeTerminal={closeTerminal} + focusHandoff={focusHandoffTerminalId === term.id} + index={index} + key={term.id} + onFocusHandoffBlur={() => clearTerminalFocusHandoff(term.id)} + term={term} + toggleHint={toggleHint} + /> + ))} +
    +
    } side="left" @@ -80,15 +213,15 @@ export function TerminalRail() { -
  • -
+
+
+
+ + ) : null, + title: id + }) + ) + } + + markCollapsePane('terminal') + markCollapsePane('logs') +}) + +afterEach(() => { + cleanup() + $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 + + let finishClose = () => {} + 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')) + }) + } finally { + 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 60dc0159fb690..bc63c3da229ec 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' @@ -68,12 +68,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 +98,7 @@ function ZoneMenu({ headerHidden, minimized, nodeId, + onToggleMinimized, targetPane }: { children: ReactNode @@ -98,6 +111,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 +138,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 +206,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 +219,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) @@ -271,12 +304,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 +388,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 +462,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 +479,7 @@ export function TreeGroup({ minimizable, minimized: node.minimized, nodeId: node.id, + onToggleMinimized: toggleCollapse, targetPane } @@ -373,10 +526,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 +539,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 +607,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 +616,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 +651,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 +700,7 @@ export function TreeGroup({ role="tab" selected={isSelected} style={{ cursor: 'grab' }} + tabIndex={paneId === activeId ? 0 : -1} > {chrome.tabLead ? ( {chrome.tabLead()} @@ -607,46 +762,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. - - - - {pane.render()} - - - - ) : ( - 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. + + + + {pane.render()} + + + + ) : ( + 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 => ( +