feat: floating workspace shortcuts and tab shortcut affordances (#6077)

* feat: add floating workspace shortcuts

* test: shim localStorage in happy-dom tests

* refactor: tighten floating shortcut tests and docs

- Drop unrelated localStorage test shim (local Node 25 only; CI runs Node 24)
- Add why-comments for minify/minimize macOS-only and unbound defaults
- Assert minify bounds via getMinifiedFloatingTerminalBounds() not magic numbers
- Cover out-of-range tab index and no-active-tab rename edge cases

* test: cover custom floating shortcut bindings on Linux/Windows

Proves user-assigned overrides for floatingWorkspace.maximize/minify fire
on platforms where the default bindings are intentionally empty.

* feat: show keybinding hints in floating panel control tooltips

Maximize/restore and minimize tooltips now surface their live shortcut as
"Label (shortcut)", matching the repo convention (e.g. ReviewPRViewAnimatedVisual).
Degrades to a bare label when the action is unbound (Win/Linux defaults,
and minimize on every platform).

* feat: rework floating panel resize shortcut to Cmd+Opt+Shift+A

- Move maximize/restore default from Cmd+Opt+Up to Cmd+Opt+Shift+A so it
  shares the Cmd+Opt+A toggle anchor and stays one-handed.
- Open-into-maximized: pressing the chord while the panel is closed opens
  it straight into the maximized state (one-shot intent bridges the
  closed->mounted gap), regardless of last saved size. While open it
  remains a maximize<->restore toggle returning to saved dimensions.
- Drop the minify action and its Cmd+Opt+Down default entirely; it was
  introduced earlier in this PR and is not in main, so this trims unshipped
  surface rather than removing an existing feature.

Maximize tooltip now surfaces the new chord. Adds panel tests for the
open-maximized intent and updates keybinding default tests.

* feat: surface tab shortcut affordances

* refactor: address 5.5 Pro review on tab shortcut affordances

- Add Darwin Option-composed (Cmd+Opt+Shift+A) maximize capture/round-trip regression
- Replace 'Unassigned' string-sentinel checks with useOptionalShortcutLabel (string | null)
- Add TTL guard + unit tests so a stale open-maximized intent cannot leak into a later open

* feat: show default agent shortcut in new tab menu

* fix: address PR review on tab tooltip a11y, i18n, and test guards

* Fix floating workspace shortcut edge cases

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Wolfie
2026-06-23 13:37:17 -07:00
committed by GitHub
co-authored by Orca Wolfgang Schoenberger Jinwoo-H
parent eb650d6cb8
commit 7c5e8a7f30
27 changed files with 1313 additions and 141 deletions
+23 -1
View File
@@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker'
import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
} from '@/lib/floating-terminal'
import {
isFloatingWorkspacePanelFocused,
isFloatingWorkspacePanelShortcut,
@@ -1395,6 +1398,7 @@ function App(): React.JSX.Element {
activeView,
activeWorktreeId,
actions,
floatingTerminalEnabled,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
@@ -1409,6 +1413,7 @@ function App(): React.JSX.Element {
activeView,
activeWorktreeId,
actions,
floatingTerminalEnabled,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
@@ -1426,6 +1431,7 @@ function App(): React.JSX.Element {
activeView,
activeWorktreeId,
actions,
floatingTerminalEnabled,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
@@ -1521,6 +1527,22 @@ function App(): React.JSX.Element {
return
}
// Why: when the floating workspace is closed, its own keydown handler is
// unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord
// here by opening the panel with a one-shot intent so it mounts straight
// into the maximized state. While the panel is open, this is a no-op: the
// panel's handler owns the maximize/restore toggle.
if (
!floatingTerminalOpen &&
matchShortcut('floatingWorkspace.maximize') &&
floatingTerminalEnabled
) {
input.preventDefault()
requestFloatingTerminalOpenMaximized()
setFloatingTerminalOpenWithFocus(true)
return
}
// Why: keep this guard. TipTap's Cmd+B bold binding depends on the
// window-level handler *not* toggling the sidebar when focus lives in an
// editable surface. The main-process before-input-event already carves out
@@ -3,7 +3,7 @@
* asserted without mounting the full Electron renderer. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings'
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
@@ -14,6 +14,10 @@ import {
getMaximizedFloatingTerminalBounds,
type FloatingTerminalPanelBounds
} from './floating-terminal-panel-bounds'
import {
consumeFloatingTerminalOpenMaximizedIntent,
requestFloatingTerminalOpenMaximized
} from '@/lib/floating-terminal'
type EffectCallback = () => void | (() => void)
@@ -32,6 +36,7 @@ type FloatingPanelStoreState = {
activeGroupIdByWorktree: Record<string, string | null>
activeTabIdByWorktree: Record<string, string | null>
expandedPaneByTabId: Record<string, boolean>
renamingTabId: string | null
createTab: (
worktreeId: string,
groupId?: string,
@@ -57,6 +62,7 @@ type FloatingPanelStoreState = {
activateTab: (tabId: string) => void
setActiveTab: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
setRenamingTabId: (tabId: string | null) => void
setTabColor: (tabId: string, color: string | null) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
makePreviewFilePermanent: (fileId: string, tabId?: string) => void
@@ -65,7 +71,11 @@ type FloatingPanelStoreState = {
browserDefaultUrl: string
keybindings?: KeybindingOverrides
tabBarOrderByWorktree: Record<string, string[]>
settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string }
settings: {
activeRuntimeEnvironmentId?: string | null
floatingTerminalCwd?: string
terminalShortcutPolicy?: TerminalShortcutPolicy
}
}
const hookRuntime = vi.hoisted(() => ({
@@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({
pickFloatingMarkdownDocument: vi.fn(),
pinFile: vi.fn(),
setActiveTab: vi.fn(),
setRenamingTabId: vi.fn(),
setTabColor: vi.fn(),
setTabCustomTitle: vi.fn(),
setTabPaneExpanded: vi.fn(),
@@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void {
activeGroupIdByWorktree: {},
activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null },
expandedPaneByTabId: {},
renamingTabId: null,
activateTab: mocks.activateTab,
closeBrowserTab: mocks.closeBrowserTab,
closeFile: mocks.closeFile,
@@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void {
pinFile: mocks.pinFile,
setActiveTab: mocks.setActiveTab,
setTabCustomTitle: mocks.setTabCustomTitle,
setRenamingTabId: mocks.setRenamingTabId,
setTabColor: mocks.setTabColor,
setTabPaneExpanded: mocks.setTabPaneExpanded,
browserDefaultUrl: 'about:blank',
@@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string {
}
function getMockedLocalStorage(): {
clear: ReturnType<typeof vi.fn>
getItem: ReturnType<typeof vi.fn>
setItem: ReturnType<typeof vi.fn>
} {
return window.localStorage as unknown as {
clear: ReturnType<typeof vi.fn>
getItem: ReturnType<typeof vi.fn>
setItem: ReturnType<typeof vi.fn>
}
@@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({
}
}
function bindFocusedFloatingPanelKeydown(element: unknown): {
keydownListener: (event: unknown) => void
panelElement: {
contains: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
closest: ReturnType<typeof vi.fn>
}
} {
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = {
contains: vi.fn().mockReturnValue(true),
focus: vi.fn(),
closest: vi.fn()
}
panelElement.closest.mockImplementation((selector: string) =>
selector === '[data-floating-terminal-panel]' ? panelElement : null
)
Object.setPrototypeOf(panelElement, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
vi.stubGlobal('document', {
activeElement: panelElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
runEffects()
const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => {
return type === 'keydown'
})?.[1] as ((event: unknown) => void) | undefined
if (!keydownListener) {
throw new Error('keydown listener not registered')
}
return { keydownListener, panelElement }
}
function makeFocusedPanelKeyEvent({
altKey = false,
code,
ctrlKey = false,
key,
metaKey = false,
preventDefault = vi.fn(),
shiftKey = false,
stopImmediatePropagation = vi.fn(),
stopPropagation = vi.fn(),
target
}: {
altKey?: boolean
code?: string
ctrlKey?: boolean
key: string
metaKey?: boolean
preventDefault?: () => void
shiftKey?: boolean
stopImmediatePropagation?: () => void
stopPropagation?: () => void
target: unknown
}): unknown {
return {
altKey,
code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key),
ctrlKey,
defaultPrevented: false,
key,
metaKey,
preventDefault,
repeat: false,
shiftKey,
stopImmediatePropagation,
stopPropagation,
target
}
}
describe('FloatingTerminalPanel close behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => {
hookRuntime.values = []
saveDialogBox.fileId = null
resetStore()
// Why: the open-maximized intent is a module singleton; drain any leftover
// from a prior test so it cannot bleed into an unrelated render.
consumeFloatingTerminalOpenMaximizedIntent()
mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' }))
mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false)
mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false)
@@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
const localStorage = {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn()
@@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2')
})
it('routes focused floating tab rename shortcuts to the active floating tab', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
const stopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
key: 'r',
metaKey: true,
preventDefault,
stopImmediatePropagation,
stopPropagation,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(stopPropagation).toHaveBeenCalledWith()
expect(stopImmediatePropagation).toHaveBeenCalledWith()
expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1')
expect(mocks.setTabCustomTitle).not.toHaveBeenCalled()
})
it('routes focused floating tab index shortcuts to the matching visible tab', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })])
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
const stopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
code: 'Digit3',
ctrlKey: true,
key: '3',
preventDefault,
stopImmediatePropagation,
stopPropagation,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(stopPropagation).toHaveBeenCalledWith()
expect(stopImmediatePropagation).toHaveBeenCalledWith()
expect(mocks.activateTab).toHaveBeenCalledWith('tab-3')
expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3')
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3')
})
it('routes focused floating tab index shortcuts across mixed visible tab types', async () => {
const state = storeBox.state as FloatingPanelStoreState
const groupId = 'floating-group'
const terminalTab = makeTab({ id: 'terminal-tab' })
const simulatorTab: Tab = {
id: 'simulator-tab',
entityId: 'simulator-tab',
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'simulator',
label: 'Mobile Emulator',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
}
const browserTab: BrowserTab = {
id: 'browser-tab',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
url: '',
title: 'Browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 2
}
const browserUnifiedTab: Tab = {
id: 'browser-unified-tab',
entityId: browserTab.id,
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'browser',
label: 'Browser',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 2
}
const terminalUnifiedTab: Tab = {
id: terminalTab.id,
entityId: terminalTab.id,
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'terminal',
label: terminalTab.title,
customLabel: terminalTab.customTitle,
color: terminalTab.color,
sortOrder: 0,
createdAt: terminalTab.createdAt
}
state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] }
state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] }
state.unifiedTabsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab]
}
state.groupsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: terminalUnifiedTab.id,
tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id],
recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id]
}
]
}
state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId }
state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id }
state.tabBarOrderByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [
terminalUnifiedTab.id,
simulatorTab.id,
browserUnifiedTab.id
]
}
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
code: 'Digit2',
ctrlKey: true,
key: '2',
preventDefault,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab')
})
it('ignores focused floating tab index shortcuts past the visible tab count', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
const stopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
code: 'Digit5',
ctrlKey: true,
key: '5',
preventDefault,
stopImmediatePropagation,
stopPropagation,
target: panelElement
})
)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
expect(stopImmediatePropagation).not.toHaveBeenCalled()
expect(mocks.activateTab).not.toHaveBeenCalled()
expect(mocks.setActiveTab).not.toHaveBeenCalled()
})
it('ignores focused floating tab rename shortcuts when no tab is active', async () => {
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
const stopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
key: 'r',
metaKey: true,
preventDefault,
stopImmediatePropagation,
stopPropagation,
target: panelElement
})
)
expect(mocks.setRenamingTabId).not.toHaveBeenCalled()
})
it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
;(storeBox.state as FloatingPanelStoreState).settings = {
...(storeBox.state as FloatingPanelStoreState).settings,
terminalShortcutPolicy: 'terminal-first'
}
const element = await renderPanel(true)
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
const target = {
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
closest: vi.fn((selector: string) =>
selector === '[data-floating-terminal-panel]' ? panelElement : null
)
}
Object.setPrototypeOf(target, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
vi.stubGlobal('document', {
activeElement: target,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
runEffects()
const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => {
return type === 'keydown'
})?.[1] as ((event: unknown) => void) | undefined
if (!keydownListener) {
throw new Error('keydown listener not registered')
}
const ctrlPreventDefault = vi.fn()
const ctrlStopPropagation = vi.fn()
const ctrlStopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
code: 'Digit2',
ctrlKey: true,
key: '2',
preventDefault: ctrlPreventDefault,
stopImmediatePropagation: ctrlStopImmediatePropagation,
stopPropagation: ctrlStopPropagation,
target
})
)
vi.stubGlobal('navigator', { userAgent: 'Linux' })
const altPreventDefault = vi.fn()
const altStopPropagation = vi.fn()
const altStopImmediatePropagation = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
altKey: true,
code: 'Digit2',
key: '2',
preventDefault: altPreventDefault,
stopImmediatePropagation: altStopImmediatePropagation,
stopPropagation: altStopPropagation,
target
})
)
expect(ctrlPreventDefault).not.toHaveBeenCalled()
expect(ctrlStopPropagation).not.toHaveBeenCalled()
expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled()
expect(altPreventDefault).not.toHaveBeenCalled()
expect(altStopPropagation).not.toHaveBeenCalled()
expect(altStopImmediatePropagation).not.toHaveBeenCalled()
expect(mocks.activateTab).not.toHaveBeenCalled()
})
it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => {
;(storeBox.state as FloatingPanelStoreState).keybindings = {
'floatingWorkspace.maximize': ['Ctrl+Alt+M']
} as unknown as KeybindingOverrides
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
altKey: true,
ctrlKey: true,
key: 'm',
preventDefault,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(getPanelStyleBounds(await renderPanel(true))).toEqual(
getMaximizedFloatingTerminalBounds()
)
})
it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => {
const savedBounds = { left: 120, top: 96, width: 760, height: 420 }
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
)
requestFloatingTerminalOpenMaximized()
await renderPanel(true)
runEffects()
expect(getPanelStyleBounds(await renderPanel(true))).toEqual(
getMaximizedFloatingTerminalBounds()
)
// Why: the intent is one-shot and must be consumed by the open transition.
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false)
})
it('does not maximize on open when no intent is set', async () => {
const savedBounds = { left: 120, top: 96, width: 760, height: 420 }
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
)
const element = await renderPanel(true)
runEffects()
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
})
it('routes focused floating workspace minimize shortcuts to close the panel', async () => {
;(storeBox.state as FloatingPanelStoreState).keybindings = {
'floatingWorkspace.minimize': ['Ctrl+Alt+N']
} as unknown as KeybindingOverrides
const onOpenChange = vi.fn()
const element = await renderPanel(true, onOpenChange)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
altKey: true,
ctrlKey: true,
key: 'n',
preventDefault,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => {
vi.stubGlobal('navigator', { userAgent: 'Linux' })
;(storeBox.state as FloatingPanelStoreState).keybindings = {
'floatingWorkspace.maximize': ['Ctrl+Alt+M']
} as unknown as KeybindingOverrides
const element = await renderPanel(true)
const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element)
const preventDefault = vi.fn()
keydownListener(
makeFocusedPanelKeyEvent({
altKey: true,
ctrlKey: true,
key: 'm',
preventDefault,
target: panelElement
})
)
expect(preventDefault).toHaveBeenCalledWith()
expect(getPanelStyleBounds(await renderPanel(true))).toEqual(
getMaximizedFloatingTerminalBounds()
)
})
it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)
@@ -58,6 +58,7 @@ import {
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
keybindingMatchesAction,
matchKeybindingDigitIndex,
type KeybindingActionId,
type KeybindingContext,
type KeybindingMatchOptions,
@@ -94,6 +95,7 @@ import {
type FloatingTerminalPanelBoundsSource
} from './floating-terminal-panel-bounds'
import { translate } from '@/i18n/i18n'
import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal'
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
const EMPTY_GROUPS: TabGroup[] = []
@@ -376,6 +378,26 @@ export function FloatingTerminalPanel({
}),
[activeGroup, groupTabs]
)
const visibleFloatingTabOrder = useMemo(
() =>
tabBarOrder.filter((visibleId) => {
const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId)
if (!tab) {
return false
}
if (tab.contentType === 'terminal') {
return terminalItems.some((item) => item.unifiedTabId === tab.id)
}
if (tab.contentType === 'browser') {
return browserItems.some((item) => item.tabId === tab.id)
}
if (tab.contentType === 'simulator') {
return simulatorItems.some((item) => item.id === tab.id)
}
return editorItems.some((item) => item.tabId === tab.id)
}),
[browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems]
)
const activeBrowserTab = activeBrowserId
? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null)
: null
@@ -963,21 +985,91 @@ export function FloatingTerminalPanel({
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target))
}, [])
const toggleMaximized = useCallback(() => {
if (maximized) {
const restoredState = restoreBoundsRef.current ?? {
committedBounds: getDefaultFloatingTerminalCommittedBounds(),
renderedBounds: getDefaultFloatingTerminalBounds(),
source: 'default' as const
}
restoreBoundsRef.current = null
boundsSourceRef.current = restoredState.source
committedBoundsRef.current = restoredState.committedBounds
const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source)
? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source)
: restoredState.renderedBounds
stagedBoundsRef.current = null
setBounds(restoredBounds)
setMaximized(false)
return
}
restoreBoundsRef.current = {
committedBounds: committedBoundsRef.current,
renderedBounds: bounds,
source: boundsSourceRef.current
}
stagedBoundsRef.current = null
setBounds(getMaximizedFloatingTerminalBounds())
setMaximized(true)
}, [bounds, maximized])
const maximizePanel = useCallback(() => {
// Why: idempotent maximize used by the open-into-maximized intent. Unlike
// toggleMaximized it never restores, and only stashes restore bounds on the
// first transition so a redundant call cannot clobber the saved size.
if (maximized) {
return
}
restoreBoundsRef.current = {
committedBounds: committedBoundsRef.current,
renderedBounds: bounds,
source: boundsSourceRef.current
}
stagedBoundsRef.current = null
setBounds(getMaximizedFloatingTerminalBounds())
setMaximized(true)
}, [bounds, maximized])
useEffect(() => {
// Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed,
// it records a one-shot intent; honor it once the panel is open so it
// starts maximized regardless of its last saved size.
if (open && consumeFloatingTerminalOpenMaximizedIntent()) {
maximizePanel()
}
}, [open, maximizePanel])
const handleFloatingPanelShortcutAction = useCallback(
(input: FloatingPanelShortcutInput, consume: () => void): boolean => {
const state = useAppStore.getState()
const platform = getShortcutPlatform()
const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy
const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target)
const context: KeybindingContext = input.doubleTapModifier
? 'app'
: isFloatingWorkspaceTerminalInputTarget(input.target)
: isFloatingTerminalInput
? 'terminal'
: 'app'
const matchOptions: KeybindingMatchOptions = {
context,
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
terminalShortcutPolicy
}
// Floating panel chrome owns these controls even when xterm has DOM focus;
// keep the rest of the shortcut table in terminal context for terminal-first.
const floatingChromeMatchOptions: KeybindingMatchOptions =
isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first'
? { context: 'app', terminalShortcutPolicy }
: matchOptions
const matches = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions)
const matchesFloatingChrome = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(
actionId,
input,
platform,
state.keybindings,
floatingChromeMatchOptions
)
if (matches('tab.newTerminal')) {
consume()
@@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({
}
return true
}
if (matchesFloatingChrome('tab.rename') && activeTab) {
consume()
state.setRenamingTabId(activeTab.id)
return true
}
const selectedTabIndex = matchKeybindingDigitIndex(
'tab.selectByIndex',
input,
platform,
state.keybindings,
matchOptions
)
if (selectedTabIndex !== null) {
const visibleId = visibleFloatingTabOrder[selectedTabIndex]
if (!visibleId) {
return false
}
consume()
activateFloatingItem(visibleId)
return true
}
if (matchesFloatingChrome('floatingWorkspace.maximize')) {
consume()
toggleMaximized()
return true
}
if (matchesFloatingChrome('floatingWorkspace.minimize')) {
consume()
onOpenChange(false)
return true
}
return false
},
[
activeClosableTab,
activeTab,
activateFloatingItem,
closeFloatingItem,
createFloatingBrowserTab,
createFloatingMarkdownTab,
@@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({
focusPanelForShortcutsAfterClose,
onOpenChange,
openFloatingMarkdownTab,
visibleFloatingItemCount
toggleMaximized,
visibleFloatingItemCount,
visibleFloatingTabOrder
]
)
@@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({
const state = useAppStore.getState()
const platform = getShortcutPlatform()
const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target)
? 'terminal'
: 'app'
const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy
const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target)
const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app'
const matchOptions: KeybindingMatchOptions = {
context,
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
terminalShortcutPolicy
}
const floatingChromeMatchOptions: KeybindingMatchOptions =
isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first'
? { context: 'app', terminalShortcutPolicy }
: matchOptions
const nativeEvent = event.nativeEvent
const isFloatingChromeShortcut =
keybindingMatchesAction(
'tab.rename',
nativeEvent,
platform,
state.keybindings,
floatingChromeMatchOptions
) ||
matchKeybindingDigitIndex(
'tab.selectByIndex',
nativeEvent,
platform,
state.keybindings,
matchOptions
) !== null ||
keybindingMatchesAction(
'floatingWorkspace.maximize',
nativeEvent,
platform,
state.keybindings,
floatingChromeMatchOptions
) ||
keybindingMatchesAction(
'floatingWorkspace.minimize',
nativeEvent,
platform,
state.keybindings,
floatingChromeMatchOptions
)
if (
!isFloatingWorkspacePanelShortcut(
@@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({
panelRef.current,
state.keybindings,
matchOptions
)
) &&
!isFloatingChromeShortcut
) {
return
}
@@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({
window.removeEventListener('blur', handleWindowBlur)
}
}, [open])
const toggleMaximized = useCallback(() => {
if (maximized) {
const restoredState = restoreBoundsRef.current ?? {
committedBounds: getDefaultFloatingTerminalCommittedBounds(),
renderedBounds: getDefaultFloatingTerminalBounds(),
source: 'default' as const
}
restoreBoundsRef.current = null
boundsSourceRef.current = restoredState.source
committedBoundsRef.current = restoredState.committedBounds
const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source)
? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source)
: restoredState.renderedBounds
stagedBoundsRef.current = null
setBounds(restoredBounds)
setMaximized(false)
return
}
restoreBoundsRef.current = {
committedBounds: committedBoundsRef.current,
renderedBounds: bounds,
source: boundsSourceRef.current
}
stagedBoundsRef.current = null
setBounds(getMaximizedFloatingTerminalBounds())
setMaximized(true)
}, [bounds, maximized])
const handleDragStart = (event: React.PointerEvent<HTMLDivElement>): void => {
if (maximized) {
return
@@ -16,6 +16,7 @@ import {
resolveTuiAgentLaunchEnv
} from '../../../../shared/tui-agent-launch-defaults'
import { translate } from '@/i18n/i18n'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
type FloatingTerminalWindowControlsProps = {
maximized: boolean
@@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = {
const controlButtonClassName =
'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground'
// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of
// surfacing the live keybinding in a tooltip as "Label (shortcut)", while
// degrading to a bare label when the action is unbound (default on Win/Linux,
// and for minimize on every platform).
function withShortcutHint(label: string, shortcutLabel: string | null): string {
return shortcutLabel ? `${label} (${shortcutLabel})` : label
}
export function FloatingTerminalWindowControls({
maximized,
onToggleMaximized,
@@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
const createTab = useAppStore((s) => s.createTab)
const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize')
const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize')
const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? [])
const defaultAgent =
@@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{maximized
? translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e',
'Restore'
? withShortcutHint(
translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e',
'Restore'
),
maximizeShortcutLabel
)
: translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023',
'Maximize'
: withShortcutHint(
translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023',
'Maximize'
),
maximizeShortcutLabel
)}
</TooltipContent>
</Tooltip>
@@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c',
'Minimize'
{withShortcutHint(
translate(
'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c',
'Minimize'
),
minimizeShortcutLabel
)}
</TooltipContent>
</Tooltip>
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react'
import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { basename, normalizeRelativePath } from '@/lib/path'
@@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { EditorFileTabCloseButton } from './EditorFileTabCloseButton'
export default function EditorFileTab({
file,
@@ -341,28 +342,14 @@ export default function EditorFileTab({
When clean: close button is shown normally (visible on active tab, on hover for others). */}
<div className="relative flex items-center justify-center w-4 h-4 shrink-0">
{file.isDirty && (
<span className="absolute size-1.5 rounded-full bg-foreground/60 group-hover:hidden" />
<span className="absolute size-1.5 rounded-full bg-foreground/60 group-hover:hidden group-focus-within:hidden" />
)}
{!isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
file.isDirty
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
: showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
// Why: simulator unified tabs reuse this tab chrome, so E2E needs
// the same stable close affordance on the real button users click.
data-tab-close-button="true"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
<EditorFileTabCloseButton
fileIsDirty={file.isDirty}
showsSelectionChrome={showsSelectionChrome}
onClose={onClose}
/>
)}
</div>
</div>
@@ -0,0 +1,56 @@
import { X } from 'lucide-react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
export function EditorFileTabCloseButton({
fileIsDirty,
showsSelectionChrome,
onClose
}: {
fileIsDirty: boolean
showsSelectionChrome: boolean
onClose: () => void
}): React.JSX.Element {
const closeShortcut = useShortcutKeyDetails('tab.close')
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
fileIsDirty
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
: showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
type="button"
// Why: simulator unified tabs reuse this tab chrome, so E2E needs
// the same stable close affordance on the real button users click.
data-tab-close-button="true"
aria-label={translate(
'auto.components.tab.bar.EditorFileTabCloseButton.4655cf570e',
'Close tab'
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="flex items-center gap-2">
<span>
{translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')}
</span>
{closeShortcut.keys.length > 0 && (
<ShortcutKeyCombo keys={closeShortcut.keys} doubleTap={closeShortcut.doubleTap} />
)}
</TooltipContent>
</Tooltip>
)
}
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W'))
const shortcutLabelMock = vi.hoisted(() => vi.fn())
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: function DropdownMenu(props: { children?: unknown }) {
@@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed
// label so the test asserts the shortcut is surfaced, not its platform glyphs.
// Why: the menu reads live shortcut bindings; stub them to fixed labels so
// the test asserts each assigned action surfaces its own shortcut chip.
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: shortcutLabelMock
useOptionalShortcutLabel: shortcutLabelMock
}))
const useAppStoreMock = Object.assign(
@@ -208,10 +208,23 @@ async function renderMenu(): Promise<unknown> {
})
}
function assignedShortcutLabel(actionId: string): string | null {
switch (actionId) {
case 'tab.rename':
return '⌘R'
case 'tab.close':
return '⌘W'
case 'tab.closeAll':
return '⌘⌥W'
default:
return null
}
}
describe('EditorFileTabContextMenu close-all shortcut', () => {
beforeEach(() => {
vi.resetModules()
shortcutLabelMock.mockReturnValue('⌘⌥W')
shortcutLabelMock.mockImplementation(assignedShortcutLabel)
vi.stubGlobal('navigator', { userAgent: 'Mac' })
})
@@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => {
vi.unstubAllGlobals()
})
it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => {
it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => {
const tree = expandNode(await renderMenu())
const menuItems = findElementsByType(tree, 'DropdownMenuItem')
const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) =>
const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename'))
const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W')
const closeAllItem = menuItems.find((item) =>
extractText(item.props.children).includes('Close All Editor Tabs')
)
expect(renameItem).toBeTruthy()
expect(closeItem).toBeTruthy()
expect(closeAllItem).toBeTruthy()
const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut')
expect(shortcut).toHaveLength(1)
expect(extractText(shortcut[0].props.children)).toBe('⌘W')
const shortcutExpectations: [ReactElementLike | undefined, string][] = [
[renameItem, '⌘R'],
[closeItem, '⌘W'],
[closeAllItem, '⌘⌥W']
]
// Why: the shortcut hint is exclusive to Close All; sibling items (Close,
// Close Tabs To The Right) must not sprout their own chips.
expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1)
for (const [item, expectedLabel] of shortcutExpectations) {
const shortcut = findElementsByType(item, 'DropdownMenuShortcut')
expect(shortcut).toHaveLength(1)
expect(extractText(shortcut[0].props.children)).toBe(expectedLabel)
}
expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3)
})
it('hides the shortcut chip when close-all is unassigned', async () => {
shortcutLabelMock.mockReturnValue('Unassigned')
shortcutLabelMock.mockReturnValue(null)
const tree = expandNode(await renderMenu())
@@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import type { OpenFile } from '../../store/slices/editor'
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
import { translate } from '@/i18n/i18n'
@@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({
onCloseToRight,
onOpenMarkdownPreview
}: EditorFileTabContextMenuProps): React.JSX.Element {
const closeAllShortcut = useShortcutLabel('tab.closeAll')
const showCloseAllShortcut = closeAllShortcut !== 'Unassigned'
const renameShortcut = useOptionalShortcutLabel('tab.rename')
const closeShortcut = useOptionalShortcutLabel('tab.close')
const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll')
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
@@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({
>
<Pencil className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')}
{renameShortcut ? <DropdownMenuShortcut>{renameShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onTogglePin}>
@@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')}
{closeShortcut ? <DropdownMenuShortcut>{closeShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24',
'Close All Editor Tabs'
)}
{showCloseAllShortcut ? (
{closeAllShortcut ? (
<DropdownMenuShortcut>{closeAllShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
@@ -1,5 +1,156 @@
import { describe, expect, it } from 'vitest'
import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton'
const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted(
() => ({
shortcutLabelMock: vi.fn<() => string | null>(),
storeState: {
settings: {
defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null,
disabledTuiAgents: [] as string[]
},
worktreesByRepo: {},
repos: [],
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
},
openSettingsPageMock: vi.fn(),
openSettingsTargetMock: vi.fn()
})
)
vi.mock('@/hooks/useDetectedAgents', () => ({
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] })
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useOptionalShortcutLabel: shortcutLabelMock
}))
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector: (state: typeof storeState) => unknown) => {
return selector(storeState)
},
{
getState: () => storeState
}
)
return { useAppStore }
})
vi.mock('@/lib/agent-catalog', async () => {
const ReactActual = (await vi.importActual('react')) as {
createElement: typeof React.createElement
}
return {
getAgentCatalog: () => [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'gemini', label: 'Gemini' }
],
AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent)
}
})
vi.mock('@/components/ui/dropdown-menu', async () => {
const ReactActual = (await vi.importActual('react')) as {
createElement: typeof React.createElement
}
return {
DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) =>
ReactActual.createElement('div', props, children),
DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) =>
ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children)
}
})
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replace(`{{${key}}}`, value),
fallback
)
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
message: vi.fn()
}
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
launchAgentInNewTab: vi.fn()
}))
function renderAgentMenuItems(): string {
return renderToStaticMarkup(
React.createElement(QuickLaunchAgentMenuItems, {
worktreeId: 'worktree-1',
groupId: 'group-1',
onFocusTerminal: vi.fn()
})
)
}
function rowMarkup(html: string, label: string): string {
const start = html.indexOf(`title="Launch ${label} in a new terminal"`)
expect(start).toBeGreaterThanOrEqual(0)
const end = html.indexOf('</div>', start)
expect(end).toBeGreaterThan(start)
return html.slice(start, end)
}
beforeEach(() => {
shortcutLabelMock.mockReset()
shortcutLabelMock.mockReturnValue(null)
openSettingsPageMock.mockReset()
openSettingsTargetMock.mockReset()
storeState.settings.defaultTuiAgent = 'codex'
storeState.settings.disabledTuiAgents = []
storeState.worktreesByRepo = {}
storeState.repos = []
storeState.openSettingsPage = openSettingsPageMock
storeState.openSettingsTarget = openSettingsTargetMock
})
describe('QuickLaunchAgentMenuItems', () => {
it('renders the new-agent shortcut next to the configured default agent only', () => {
shortcutLabelMock.mockReturnValue('⌘⌥T')
const html = renderAgentMenuItems()
expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1)
expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T')
expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T')
expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T')
})
it('hides the default-agent shortcut when the action is unbound', () => {
shortcutLabelMock.mockReturnValue(null)
const html = renderAgentMenuItems()
expect(html).not.toContain('data-dropdown-shortcut="true"')
})
it('does not label an auto-picked or blank default as configured', () => {
shortcutLabelMock.mockReturnValue('⌘⌥T')
storeState.settings.defaultTuiAgent = null
expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"')
storeState.settings.defaultTuiAgent = 'blank'
expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"')
})
})
describe('shouldShowLaunchWatchdogTimeout', () => {
it('does not report slow agent readiness once a PTY exists', () => {
@@ -1,10 +1,11 @@
import React, { useCallback } from 'react'
import { Settings as SettingsIcon } from 'lucide-react'
import { toast } from 'sonner'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
@@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({
const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? [])
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
@@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({
{agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
<DropdownMenuItem
key={agent}
@@ -209,7 +213,10 @@ function QuickLaunchAgentMenuItemsInner({
)}
>
<AgentIcon agent={agent} size={14} />
{label}
<span className="flex-1">{label}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
@@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
formatShortcutLabel: () => '⌘⇧\\'
formatShortcutLabel: () => '⌘⇧\\',
useOptionalShortcutLabel: () => '⌘W',
useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false })
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
@@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator() {
return { type: 'DropdownMenuSeparator', props: {} }
},
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator() {
return { type: 'DropdownMenuSeparator', props: {} }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
@@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration'
import { useTabAgent } from '@/lib/use-tab-agent'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import type { TerminalTab } from '../../../../shared/types'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
@@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
type SortableTabProps = {
tab: TerminalTab
@@ -207,6 +209,7 @@ export default function SortableTab({
disabled: isEditing
})
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
const closeShortcut = useShortcutKeyDetails('tab.close')
const tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
<div
@@ -403,42 +406,51 @@ export default function SortableTab({
</button>
)}
{!isEditing && !isPinned && (
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
// Why: per-tab close affordance needs a stable accessible name so
// E2E specs can drive the same path a user takes (hover → click X)
// instead of bypassing the render layer by calling closeTab() on
// the store — a store-only assertion would pass even if this
// button had been accidentally unmounted.
aria-label={translate(
'auto.components.tab.bar.SortableTab.6df69d9388',
'Close tab {{value0}}',
{ value0: tabTitle }
)}
type="button"
data-tab-close-button="true"
onPointerDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onMouseDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X className="w-3 h-3" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:text-foreground focus-visible:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted focus-visible:!text-foreground focus-visible:!bg-muted'
}`}
// Why: per-tab close affordance needs a stable accessible name so
// E2E specs can drive the same path a user takes (hover, then X)
// instead of bypassing the render layer by calling closeTab() on
// the store. A store-only assertion would miss an unmounted button.
aria-label={translate(
'auto.components.tab.bar.SortableTab.6df69d9388',
'Close tab {{value0}}',
{ value0: tabTitle }
)}
type="button"
data-tab-close-button="true"
onPointerDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onMouseDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="flex items-center gap-2">
<span>{translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')}</span>
{closeShortcut.keys.length > 0 && (
<ShortcutKeyCombo keys={closeShortcut.keys} doubleTap={closeShortcut.doubleTap} />
)}
</TooltipContent>
</Tooltip>
)}
</div>
)
@@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
formatShortcutLabel: () => '⌘D'
formatShortcutLabel: () => '⌘D',
useOptionalShortcutLabel: () => '⌘D'
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
@@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
@@ -126,6 +126,8 @@ export function SortableTabContextMenu({
}
requestActiveTerminalPaneSplit({ tabId: tab.id, direction })
}
const closeShortcut = useOptionalShortcutLabel('tab.close')
const renameShortcut = useOptionalShortcutLabel('tab.rename')
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
@@ -165,6 +167,7 @@ export function SortableTabContextMenu({
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose(tab.id)} disabled={isPinned}>
{translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')}
{closeShortcut ? <DropdownMenuShortcut>{closeShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseOthers(tab.id)} disabled={tabCount <= 1}>
{translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')}
@@ -178,6 +181,7 @@ export function SortableTabContextMenu({
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRenameOpen}>
{translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')}
{renameShortcut ? <DropdownMenuShortcut>{renameShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<div className="px-2 pt-1.5 pb-1">
<div className="text-xs font-medium text-muted-foreground mb-1.5">
@@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => {
)
expect(menuLabels[0]).toContain('New Markdown')
expect(menuLabels[1]).toBe('Open Markdown...')
expect(menuLabels[1]).toContain('Open Markdown...')
expect(menuLabels[2]).toContain('New Terminal')
expect(menuLabels[3]).toContain('New Browser Tab')
})
@@ -51,7 +51,7 @@ import {
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel'
import {
type BuiltInWindowsTerminalShell,
WINDOWS_GIT_BASH_SHELL
@@ -272,6 +272,7 @@ function TabBarInner({
const newBrowserShortcut = useShortcutLabel('tab.newBrowser')
const newSimulatorShortcut = useShortcutLabel('tab.newSimulator')
const newFileShortcut = useShortcutLabel('tab.newMarkdown')
const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown')
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
@@ -761,6 +762,9 @@ function TabBarInner({
>
<FileText className="size-4 text-muted-foreground" />
{translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')}
{openMarkdownShortcut ? (
<DropdownMenuShortcut>{openMarkdownShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
) : null
const mobileEmulatorIntroMenuBlock =
@@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string {
return match[0]
}
function textSpanHtml(markup: string, text: string): string {
const textIndex = markup.indexOf(`>${text}</span>`)
expect(textIndex).toBeGreaterThanOrEqual(0)
const tagStart = markup.lastIndexOf('<span', textIndex)
expect(tagStart).toBeGreaterThanOrEqual(0)
const spanEnd = markup.indexOf('</span>', textIndex)
expect(spanEnd).toBeGreaterThan(textIndex)
return markup.slice(tagStart, spanEnd + '</span>'.length)
}
function expectTabContainerWidth(markup: string, root: string): void {
const container = firstOpeningTag(markup)
const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]'
@@ -357,6 +370,7 @@ describe('tab title tooltips', () => {
expect(root).toContain('role="tab"')
expect(root).toContain('tabindex="0"')
expect(root).toContain('data-tab-id="editor-tab-1"')
expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed')
expectTabContainerWidth(markup, root)
})
})
@@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string {
return formatShortcutLabel(actionId, keybindings)
}
// Why: returns null for unbound actions instead of the display sentinel
// 'Unassigned', so callers decide whether to render a hint without coupling
// UI logic to formatter copy (which may change or become localized).
export function formatOptionalShortcutLabel(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
): string | null {
const platform = getShortcutPlatform()
const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides)
if (bindings.length === 0) {
return null
}
return formatKeybindingList(bindings, platform)
}
export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null {
const keybindings = useAppStore((state) => state.keybindings)
return formatOptionalShortcutLabel(actionId, keybindings)
}
export function formatShortcutKeys(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
+6 -1
View File
@@ -2500,7 +2500,8 @@
"SortableTab": {
"ab19f603eb": "Rename tab {{value0}}",
"6df69d9388": "Close tab {{value0}}",
"fdb2691425": "Collapse pane"
"fdb2691425": "Collapse pane",
"95db5f2f7d": "Close tab"
},
"SortableTabContextMenu": {
"35e8892fd0": "Tab Color",
@@ -2624,6 +2625,10 @@
"down": "Down",
"up": "Up",
"moveToPaneColumn": "Move Tab to Split"
},
"EditorFileTabCloseButton": {
"4655cf570e": "Close tab",
"a768f428f1": "Close tab"
}
}
},
+6 -1
View File
@@ -2500,7 +2500,8 @@
"SortableTab": {
"ab19f603eb": "Cambiar nombre de pestaña {{value0}}",
"6df69d9388": "Cerrar pestaña {{value0}}",
"fdb2691425": "Contraer panel"
"fdb2691425": "Contraer panel",
"95db5f2f7d": "Cerrar pestaña"
},
"SortableTabContextMenu": {
"35e8892fd0": "Color de pestaña",
@@ -2624,6 +2625,10 @@
"down": "Abajo",
"up": "Arriba",
"moveToPaneColumn": "Mover pestaña a división"
},
"EditorFileTabCloseButton": {
"4655cf570e": "Cerrar pestaña",
"a768f428f1": "Cerrar pestaña"
}
}
},
+6 -1
View File
@@ -2500,7 +2500,8 @@
"SortableTab": {
"ab19f603eb": "タブ {{value0}} の名前を変更します",
"6df69d9388": "タブ {{value0}} を閉じる",
"fdb2691425": "ペインを折りたたむ"
"fdb2691425": "ペインを折りたたむ",
"95db5f2f7d": "タブを閉じる"
},
"SortableTabContextMenu": {
"35e8892fd0": "タブの色",
@@ -2624,6 +2625,10 @@
"down": "下",
"up": "上",
"moveToPaneColumn": "タブを分割へ移動"
},
"EditorFileTabCloseButton": {
"4655cf570e": "タブを閉じる",
"a768f428f1": "タブを閉じる"
}
}
},
+6 -1
View File
@@ -2500,7 +2500,8 @@
"SortableTab": {
"ab19f603eb": "탭 이름 바꾸기 {{value0}}",
"6df69d9388": "탭 닫기 {{value0}}",
"fdb2691425": "창 축소"
"fdb2691425": "창 축소",
"95db5f2f7d": "탭 닫기"
},
"SortableTabContextMenu": {
"35e8892fd0": "탭 색상",
@@ -2624,6 +2625,10 @@
"down": "아래",
"up": "위",
"moveToPaneColumn": "탭을 분할로 이동"
},
"EditorFileTabCloseButton": {
"4655cf570e": "탭 닫기",
"a768f428f1": "탭 닫기"
}
}
},
+6 -1
View File
@@ -2500,7 +2500,8 @@
"SortableTab": {
"ab19f603eb": "重命名选项卡 {{value0}}",
"6df69d9388": "关闭选项卡 {{value0}}",
"fdb2691425": "折叠窗格"
"fdb2691425": "折叠窗格",
"95db5f2f7d": "关闭选项卡"
},
"SortableTabContextMenu": {
"35e8892fd0": "标签颜色",
@@ -2624,6 +2625,10 @@
"down": "下",
"up": "上",
"moveToPaneColumn": "将标签页移至拆分"
},
"EditorFileTabCloseButton": {
"4655cf570e": "关闭选项卡",
"a768f428f1": "关闭选项卡"
}
}
},
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
consumeFloatingTerminalOpenMaximizedIntent,
requestFloatingTerminalOpenMaximized
} from './floating-terminal'
describe('floating terminal open-maximized intent', () => {
afterEach(() => {
vi.useRealTimers()
// Drain any leftover intent so it cannot bleed into an unrelated test.
consumeFloatingTerminalOpenMaximizedIntent()
})
it('returns true exactly once after a request', () => {
requestFloatingTerminalOpenMaximized()
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true)
// One-shot: a second consume without a new request is false.
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false)
})
it('returns false when no request was made', () => {
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false)
})
it('expires a stale intent so an abandoned open does not leak into a later open', () => {
vi.useFakeTimers()
requestFloatingTerminalOpenMaximized()
// Why: the open was abandoned (prevented/interrupted before the panel
// mounted); a much-later ordinary open must not consume the stale intent.
vi.advanceTimersByTime(2001)
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false)
})
it('still honors an intent consumed within the same interaction window', () => {
vi.useFakeTimers()
requestFloatingTerminalOpenMaximized()
vi.advanceTimersByTime(50)
expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true)
})
})
+27
View File
@@ -1 +1,28 @@
export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal'
// Why: maximize/restore lives in the panel's own keydown handler, but that
// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is
// pressed with the panel closed, App opens it and records a one-shot intent
// here so the freshly mounted panel starts maximized instead of at its last
// saved size. A module singleton (not a prop) bridges the closed→mounted gap
// that React state cannot, and is consumed exactly once.
let openMaximizedIntentAt: number | null = null
// Why: the panel mounts within the same interaction as the request, so an
// intent older than this window means the open was abandoned (prevented or
// interrupted before mount). Expiring it stops a stale intent from leaking
// into a later ordinary open and maximizing it unexpectedly.
const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000
export function requestFloatingTerminalOpenMaximized(): void {
openMaximizedIntentAt = Date.now()
}
export function consumeFloatingTerminalOpenMaximizedIntent(): boolean {
if (openMaximizedIntentAt === null) {
return false
}
const requestedAt = openMaximizedIntentAt
openMaximizedIntentAt = null
return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS
}
+102
View File
@@ -21,6 +21,7 @@ import {
normalizeKeybindingListForAction,
normalizeKeybindingList
} from './keybindings'
import type { KeybindingActionId, KeybindingPlatform } from './keybindings'
import { ALL_TUI_AGENTS } from './tui-agent-display-names'
describe('keybindings', () => {
@@ -250,6 +251,22 @@ describe('keybindings', () => {
'linux'
)
).toBe(false)
expect(
keybindingMatchesAction(
'tab.rename',
{
key: 'r',
code: 'KeyR',
meta: true,
control: false,
alt: false,
shift: false
},
'darwin',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBe(false)
// Why: tab.rename (Mod+R) intentionally shares its binding with
// browser.reload, but the two live in different scopes (tabs vs browser),
@@ -439,6 +456,65 @@ describe('keybindings', () => {
)
})
it('defines floating workspace panel action metadata', () => {
const actionIds = [
'floatingWorkspace.maximize' as KeybindingActionId,
'floatingWorkspace.minimize' as KeybindingActionId
] as const
for (const actionId of actionIds) {
expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId })
}
})
it('assigns the floating workspace maximize default only on macOS', () => {
const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId
expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A'])
expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([])
expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([])
})
it('captures and round-trips the macOS Option-composed maximize chord', () => {
const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId
// Why: macOS Option+A composes to a glyph (å), so capture must resolve the
// chord through the physical-code fallback rather than the composed key,
// matching the matcher so a user override round-trips to the same binding.
const macComposedMaximize = {
key: 'å',
code: 'KeyA',
meta: true,
control: false,
alt: true,
shift: true
}
expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({
ok: true,
value: 'Mod+Alt+Shift+A'
})
expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true)
// The captured override formats back to the same effective shortcut.
expect(
getEffectiveKeybindingsForAction(maximizeAction, 'darwin', {
[maximizeAction]: ['Mod+Alt+Shift+A']
})
).toEqual(['Mod+Alt+Shift+A'])
expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A')
})
it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => {
const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId
for (const platform of platforms) {
expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([])
}
expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([
'Mod+Alt+A'
])
})
it('defines a macOS-only default for the new agent tab shortcut', () => {
expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T'])
expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([])
@@ -533,6 +609,32 @@ describe('keybindings', () => {
).toBe(true)
})
it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => {
const panelFocus = {
context: 'app',
terminalShortcutPolicy: 'terminal-first'
} as const
expect(
keybindingMatchesAction(
'tab.rename',
{ key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false },
'darwin',
undefined,
panelFocus
)
).toBe(true)
expect(
matchKeybindingDigitIndex(
'tab.selectByIndex',
{ key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false },
'linux',
undefined,
panelFocus
)
).toBe(3)
})
it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => {
const deleteBinding = {
key: 'Backspace',
+53
View File
@@ -50,6 +50,8 @@ export type KeybindingActionId =
| 'sidebar.ports.toggle'
| 'sidebar.focusWorktreeList'
| 'floatingTerminal.toggle'
| 'floatingWorkspace.maximize'
| 'floatingWorkspace.minimize'
| 'zoom.in'
| 'zoom.out'
| 'zoom.reset'
@@ -408,6 +410,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
defaultBindings: platformBindings(['Mod+Alt+A']),
allowInTerminal: true
},
{
id: 'floatingWorkspace.maximize',
title: 'Maximize Floating Workspace Panel',
group: 'Global',
scope: 'global',
searchKeywords: [
'shortcut',
'floating',
'workspace',
'panel',
'floating workspace',
'workspace panel',
'maximize',
'expand'
],
// Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so
// maximize/restore lives on the same key anchor and stays one-handed,
// instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default;
// Linux/Windows stay unbound for users to assign.
defaultBindings: {
darwin: ['Mod+Alt+Shift+A'],
linux: [],
win32: []
},
allowInTerminal: true
},
{
id: 'floatingWorkspace.minimize',
title: 'Minimize Floating Workspace Panel',
group: 'Global',
scope: 'global',
searchKeywords: [
'shortcut',
'floating',
'workspace',
'panel',
'floating workspace',
'workspace panel',
'minimize',
'hide'
],
// Why: intentionally unbound on every platform. floatingTerminal.toggle
// already owns the default show/hide chord; this action exists only so
// users can bind an explicit "hide the focused panel" shortcut in Settings.
defaultBindings: {
darwin: [],
linux: [],
win32: []
},
allowInTerminal: true
},
{
id: 'zoom.in',
title: 'Zoom In',