diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index dc0b24409..a8e75e6ce 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -4,7 +4,8 @@ import { assertNewChatTileVisible, assertNewTabMenuTriggerVisible, assertSingleNewTabButton, - pressNewTabShortcut, + openNewTabMenuWithShortcut, + pressDirectNewTabShortcut, clickNewChat, clickNewTerminal, countTabsOfKind, @@ -39,12 +40,32 @@ test.afterAll(async () => { // ═══════════════════════════════════════════════════════════════════════════ test.describe("Tab creation", () => { - test("Cmd+T opens a new agent tab with composer", async ({ page }) => { + test("retained inactive workspaces cannot own New tab shortcuts", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); + const workspaceUrl = page.url(); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await pressNewTabShortcut(page); + await page.keyboard.press(`${modifier}+Comma`); + await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible(); - await expectComposerVisible(page); + await page.keyboard.press(`${modifier}+t`); + await expect(page.getByRole("menuitem", { name: /New agent/ })).toHaveCount(0); + + await page.goto(workspaceUrl); + await assertNewTabMenuTriggerVisible(page); + const countBefore = await countTabsOfKind(page, "draft"); + await pressDirectNewTabShortcut(page, "a"); + await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1); + }); + + test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + const countBefore = await countTabsOfKind(page, "draft"); + + await openNewTabMenuWithShortcut(page); + + await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore); + await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible(); }); test("opening two new tabs creates two draft tabs", async ({ page }) => { @@ -52,20 +73,45 @@ test.describe("Tab creation", () => { const countBefore = await countTabsOfKind(page, "draft"); - await pressNewTabShortcut(page); + await pressDirectNewTabShortcut(page, "a"); await expect .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) .toBe(countBefore + 1); const countAfterFirst = await countTabsOfKind(page, "draft"); - // Blur the composer so the second shortcut isn't swallowed by the focused input - await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.()); - await pressNewTabShortcut(page); + await pressDirectNewTabShortcut(page, "a"); await expect .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) .toBe(countAfterFirst + 1); }); + test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + await openNewTabMenuWithShortcut(page); + + const agent = page.getByRole("menuitem", { name: /New agent/ }); + const terminal = page.getByRole("menuitem", { name: /New terminal/ }); + const changes = page.getByRole("menuitem", { name: /Changes/ }); + const files = page.getByRole("menuitem", { name: /Files/ }); + const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; + await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); + await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); + await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`)); + await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`)); + await expect(agent).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await expect(terminal).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(agent).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "New tab" })).toBeFocused(); + await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + test("clicking new agent tab creates a draft tab", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); @@ -97,6 +143,22 @@ test.describe("Tab creation", () => { await assertNewChatTileVisible(page); await assertNewTabMenuTriggerVisible(page); }); + + test("never shades the New tab button", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + + const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(); + const scrollArea = tabRow.getByTestId("workspace-tabs-scroll"); + const newTabButtonInScroll = await scrollArea + .getByTestId("workspace-new-tab-menu-trigger") + .count(); + const scrollShades = await tabRow + .locator('[data-testid^="workspace-tabs-scroll-shade-"]') + .count(); + + expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true); + await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible(); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index 17c97687f..c2a4fffc8 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -62,12 +62,22 @@ export async function getActiveTabTestId(page: Page): Promise { // ─── Tab actions ─────────────────────────────────────────────────────────── -/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */ +/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */ export async function pressNewTabShortcut(page: Page): Promise { const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+t`); } +export async function openNewTabMenuWithShortcut(page: Page): Promise { + await pressNewTabShortcut(page); + await expect(page.getByRole("menuitem", { name: /New agent/ })).toBeVisible(); +} + +export async function pressDirectNewTabShortcut(page: Page, key: string): Promise { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+Shift+${key}`); +} + // ─── Tab bar assertions ─────────────────────────────────────────────────── /** Assert the inline plus button is visible in the tab bar. */ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 222766065..67fa8f598 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -93,7 +93,10 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import { useOpenAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelsProvider } from "@/mobile-panels/provider"; import { I18nProvider } from "@/i18n/provider"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { + KeyboardActionDispatcherProvider, + useKeyboardActionDispatcher, +} from "@/keyboard/keyboard-action-dispatcher-context"; import { polyfillCrypto } from "@/polyfills/crypto"; import { polyfillNavigator } from "@/polyfills/navigator"; import { queryClient } from "@/data/query-client"; @@ -455,6 +458,7 @@ interface AppContainerProps { const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); const daemons = useHosts(); const { settings, updateSettings } = useAppSettings(); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); @@ -496,7 +500,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon scope: "sidebar", }), }); - }, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]); + }, [ + closeDesktopAgentList, + closeDesktopFileExplorer, + keyboardActionDispatcher, + openDesktopAgentList, + ]); // TODO: stop matching pathname here as a branch. `chromeEnabled` should not // conflate workspace/project-specific chrome (sidebar, mobile gesture) with // global concerns like keyboard shortcuts. Split those out so settings (and @@ -981,17 +990,19 @@ function RuntimeProviders({ children }: { children: ReactNode }) { // context and need one shared provider for sibling sheets to stack. function RootProviders({ children }: { children: ReactNode }) { return ( - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ); } diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 0daac4cb2..b0272300a 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -44,7 +44,7 @@ import { type CommandCenterScope, } from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, @@ -220,6 +220,7 @@ interface CommandCenterState { } function useCommandCenterState(): CommandCenterState { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope); @@ -321,7 +322,7 @@ function useCommandCenterState(): CommandCenterState { keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }), }); return cancel; - }, [open]); + }, [keyboardActionDispatcher, open]); return { open, diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index faa89e0fc..c20b5060c 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -16,7 +16,7 @@ import { withUnistyles } from "react-native-unistyles"; import { getIsElectronRuntime } from "@/constants/layout"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; @@ -92,6 +92,7 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) { } export function CommandCenterRootActions() { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const { overrides } = useKeyboardShortcutOverrides(); const shortcutsAvailable = useKeyboardShortcutsAvailable(); @@ -266,6 +267,7 @@ export function CommandCenterRootActions() { }, [ groupMode, homeRoute, + keyboardActionDispatcher, openAddProject, overrides, schedulesRoute, diff --git a/packages/app/src/command-center/workspace-contributions.test.ts b/packages/app/src/command-center/workspace-contributions.test.ts index 346429f71..5dfa82e0e 100644 --- a/packages/app/src/command-center/workspace-contributions.test.ts +++ b/packages/app/src/command-center/workspace-contributions.test.ts @@ -114,7 +114,7 @@ describe("workspace command center contributions", () => { for (const contribution of contributions) contribution.run(); expect(fixture.dispatched).toEqual([ - { id: "workspace.tab.new", scope: "workspace" }, + { id: "workspace.agent.new", scope: "workspace" }, { id: "workspace.terminal.new", scope: "workspace" }, { id: "workspace.browser.new", scope: "workspace" }, { id: "workspace.pane.split.right", scope: "workspace" }, diff --git a/packages/app/src/command-center/workspace-contributions.ts b/packages/app/src/command-center/workspace-contributions.ts index 7fd96c55e..aa743f2e3 100644 --- a/packages/app/src/command-center/workspace-contributions.ts +++ b/packages/app/src/command-center/workspace-contributions.ts @@ -108,7 +108,7 @@ export function buildWorkspaceCommandCenterContributions( keywords: ["tab", "new", "agent", "chat"], icon: source.icons.newAgent, shortcutKeys: source.shortcuts.newAgent, - action: { id: "workspace.tab.new", scope: "workspace" }, + action: { id: "workspace.agent.new", scope: "workspace" }, visibility: "always", }), ]; diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 4963fd835..b6f867eb1 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -10,7 +10,7 @@ import { resolveShortcutKeysForAction, type ShortcutOverrides, } from "@/keyboard/keyboard-shortcuts"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; @@ -55,6 +55,7 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma } export function useWorkspaceCommandCenterActions(): void { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const selection = useActiveWorkspaceSelection(); const serverId = selection?.serverId ?? null; @@ -96,7 +97,7 @@ export function useWorkspaceCommandCenterActions(): void { }, runGitAction, }), - [gitActions, isCompact, overrides, runGitAction, t], + [gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t], ); useCommandCenterActions({ diff --git a/packages/app/src/components/ui/menu/menu-item.tsx b/packages/app/src/components/ui/menu/menu-item.tsx index c1072db72..28745b592 100644 --- a/packages/app/src/components/ui/menu/menu-item.tsx +++ b/packages/app/src/components/ui/menu/menu-item.tsx @@ -207,7 +207,6 @@ export function MenuItem({ tooltip, }: PropsWithChildren): ReactElement { const { selectItem } = useMenuContext("MenuItem"); - const isPending = status === "pending" || loading; const isSuccess = status === "success"; const isDisabled = disabled || isPending || isSuccess; @@ -230,12 +229,17 @@ export function MenuItem({ }, [isDisabled, selectItem, onSelect, closeOnSelect]); const itemPressableStyle = useCallback( - ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + ({ + pressed, + hovered = false, + focused = false, + }: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) => [ styles.item, active ? styles.itemActive : null, isDisabled ? styles.itemDisabled : null, muted && !isDisabled ? styles.itemMuted : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, + focused && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, ], [active, isDisabled, muted], @@ -250,11 +254,17 @@ export function MenuItem({ ], [destructive, isSuccess, muted, isDisabled], ); + const itemDataSet = useMemo( + () => ({ menuItem: "true", menuDisabled: isDisabled ? "true" : "false" }), + [isDisabled], + ); const content = ( ({ borderWidth: theme.borderWidth[1], borderColor: "transparent", borderRadius: theme.borderRadius.md, + outlineWidth: 0, + outlineColor: "transparent", }, itemHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index fb75fa6bf..32aed88ab 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useId, useMemo, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useState, + type ReactElement, + type RefObject, +} from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -15,6 +23,7 @@ import { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; +import type { KeyboardFocusScope } from "@/keyboard/actions"; import { getOverlayRoot, OverlayLayerProvider, @@ -230,6 +239,7 @@ export interface AnchoredSurfaceProps { onPointerEnter?: () => void; onPointerLeave?: () => void; testID?: string; + keyboardFocusScope?: KeyboardFocusScope; children: ReactElement; } @@ -258,6 +268,7 @@ export function AnchoredSurface({ onPointerEnter, onPointerLeave, testID, + keyboardFocusScope, children, }: AnchoredSurfaceProps): ReactElement | null { const { t } = useTranslation(); @@ -281,6 +292,17 @@ export function AnchoredSurface({ revision, }); + useEffect(() => { + if (!isWeb || !open || !contentSize || typeof document === "undefined") return undefined; + const frame = requestAnimationFrame(() => { + document + .getElementById(surfaceNativeID) + ?.querySelector('[data-menu-item="true"]:not([data-menu-disabled="true"])') + ?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [contentSize, open, surfaceNativeID]); + const frameStyle = useMemo>(() => { const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth @@ -315,6 +337,13 @@ export function AnchoredSurface({ () => [visibleContentSize ? { height: visibleContentSize.height } : null], [visibleContentSize], ); + const surfaceDataSet = useMemo( + () => ({ + menuSurface: "true", + ...(keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : null), + }), + [keyboardFocusScope], + ); if (!open) return null; @@ -346,6 +375,7 @@ export function AnchoredSurface({ tabIndex={-1} nativeID={surfaceNativeID} testID={testID} + dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} entering={contentEntering} @@ -385,21 +415,56 @@ export function AnchoredSurface({ export function MenuOverlay({ visible, onClose, + restoreFocusRef, children, }: { visible: boolean; onClose: () => void; + restoreFocusRef?: RefObject; children: ReactElement | null; }): ReactElement | null { const floatingLayer = useOverlayLayer("floating"); const handleWebOverlayKeyDown = useCallback( (event: KeyboardEvent) => { - if (event.key !== "Escape") return false; - event.preventDefault(); - event.stopPropagation(); - onClose(); - return true; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return true; + } + + const target = event.target instanceof Element ? event.target : null; + const surface = target?.closest('[data-menu-surface="true"]'); + if (!surface) return false; + const items = Array.from( + surface.querySelectorAll( + '[data-menu-item="true"]:not([data-menu-disabled="true"])', + ), + ); + if (items.length === 0) return false; + const currentIndex = items.findIndex((item) => item === document.activeElement); + let nextIndex: number | null = null; + if (event.key === "ArrowDown") + nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length; + if (event.key === "ArrowUp") + nextIndex = + currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = items.length - 1; + if (nextIndex !== null) { + event.preventDefault(); + event.stopPropagation(); + items[nextIndex]?.focus(); + return true; + } + if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) { + event.preventDefault(); + event.stopPropagation(); + items[currentIndex]?.click(); + return true; + } + return false; }, [onClose], ); @@ -407,6 +472,7 @@ export function MenuOverlay({ active: isWeb && visible, layer: floatingLayer, onKeyDown: handleWebOverlayKeyDown, + restoreFocusRef, }); if (!visible) return null; diff --git a/packages/app/src/components/ui/menu/menu-root.tsx b/packages/app/src/components/ui/menu/menu-root.tsx index 08c4b2407..d1a59bbfd 100644 --- a/packages/app/src/components/ui/menu/menu-root.tsx +++ b/packages/app/src/components/ui/menu/menu-root.tsx @@ -1,6 +1,7 @@ import { forwardRef, useCallback, + useMemo, type PropsWithChildren, type ReactElement, type ReactNode, @@ -20,6 +21,7 @@ import { useMenuState, type MenuCompactMode, } from "./menu-context"; +import { isWeb } from "@/constants/platform"; /** * Owns one menu's state. Wrap a trigger and a `MenuSurface` in it. @@ -67,7 +69,7 @@ function assignRef(ref: Ref | undefined, value: T | null): void { } export const MenuTrigger = forwardRef(function MenuTrigger( - { children, disabled, style, ...props }, + { children, disabled, style, accessibilityState, ...props }, forwardedRef, ): ReactElement { const ctx = useMenuContext("MenuTrigger"); @@ -102,13 +104,23 @@ export const MenuTrigger = forwardRef(function MenuTrigg }, [children, ctx.open], ); + const resolvedAccessibilityState = useMemo( + () => ({ ...accessibilityState, disabled: Boolean(disabled), expanded: ctx.open }), + [accessibilityState, ctx.open, disabled], + ); + const webExpandedState = useMemo( + () => (isWeb ? ({ "aria-expanded": ctx.open } as const) : null), + [ctx.open], + ); return ( diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index c94721fce..4d3fb5f0d 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -27,6 +27,7 @@ import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; import type { Alignment, Placement } from "./menu-anchor"; +import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -105,6 +106,8 @@ export interface MenuSurfaceProps { horizontalPadding?: number; scrollable?: boolean; testID?: string; + /** Limits ordinary app shortcuts to the time this menu owns keyboard focus. */ + keyboardFocusScope?: KeyboardFocusScope; } /** @@ -188,6 +191,7 @@ function MenuPopoverSurface({ horizontalPadding = 16, scrollable = false, testID, + keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { const menu = useMenuContext("MenuSurface"); const { value: surfaceValue, getAnchor } = useSubAnchors(); @@ -208,7 +212,7 @@ function MenuPopoverSurface({ return ( - + <> {children} @@ -304,6 +309,7 @@ function MenuSheetSurface({ pages = [], sheetTitle, testID, + keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); @@ -318,6 +324,10 @@ function MenuSheetSurface({ }), [safeAreaInsets.bottom], ); + const sheetDataSet = useMemo( + () => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined), + [keyboardFocusScope], + ); const handleClose = useCallback(() => menu.setOpen(false), [menu]); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ @@ -369,6 +379,7 @@ function MenuSheetSurface({ keyboardBlurBehavior="restore" > inputRef.current.handle(action), }); - }, [input.handlerId, input.priority, actionsKey]); + }, [actionsKey, input.handlerId, input.priority, keyboardActionDispatcher]); } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index d7abeb1db..04d89cd6b 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -5,7 +5,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { type ChordState, type KeyboardShortcutInput, @@ -37,6 +37,7 @@ import { navigateToLastWorkspace, useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; +import { dispatchTopWebOverlayKeyDown } from "@/lib/overlay-root"; export function useKeyboardShortcuts({ enabled, @@ -55,6 +56,7 @@ export function useKeyboardShortcuts({ exitFocusMode: () => void; cycleTheme?: () => void; }) { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); const pathname = usePathname(); const router = useRouter(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); @@ -269,7 +271,6 @@ export function useKeyboardShortcuts({ }, bindings, }); - chordStateRef.current = result.nextChordState; if ( shouldPublishBrowserShortcutPolicy({ @@ -313,6 +314,10 @@ export function useKeyboardShortcuts({ return; } + if (dispatchTopWebOverlayKeyDown(event)) { + return; + } + // During IME composition, Enter confirms the candidate selection and must // not route through global shortcuts like message send. if (isImeComposingKeyboardEvent(event)) { @@ -405,6 +410,7 @@ export function useKeyboardShortcuts({ isMac, isMobile, isWorkspaceFocusModeEnabled, + keyboardActionDispatcher, openProjectPickerAction, pathname, publishBrowserShortcutPolicy, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index c672c3e15..3f11aa8be 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -497,6 +497,12 @@ describe("translation resources", () => { expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview."); expect(en.workspace.tabs.explorer.changes).toBe("Changes"); expect(en.workspace.tabs.explorer.files).toBe("Files"); + expect(en.workspace.tabs.actions.maximizePane).toBe("Maximize pane"); + expect(en.workspace.tabs.actions.restorePane).toBe("Restore pane"); + expect(en.settings.shortcuts.help.toggleExplorerPaneMaximization).toBe( + "Toggle Explorer pane maximization", + ); + expect(en.branchSwitcher.triggerTooltip).toBe("Switch workspace branch"); expect(en.branchSwitcher.uncommittedTitle).toBe("Uncommitted changes"); expect(en.branchSwitcher.uncommittedMessage).toBe( "You have uncommitted changes. Stash them before switching branches?", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 43e368888..e06d32360 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -592,6 +592,8 @@ export const ar: TranslationResources = { preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminalTooltip: "جارٍ تحضير المحطة...", newBrowser: "متصفح جديد", + maximizePane: "تكبير الجزء", + restorePane: "استعادة الجزء", exitFocusMode: "إنهاء وضع التركيز", splitRight: "تقسيم الجزء الأيمن", splitDown: "تقسيم الجزء لأسفل", @@ -1353,6 +1355,7 @@ export const ar: TranslationResources = { openPath: "فتح المسار", }, branchSwitcher: { + triggerTooltip: "تبديل فرع مساحة العمل", currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", placeholder: "تبديل الفرع...", searchPlaceholder: "تصفية الفروع...", @@ -2053,6 +2056,7 @@ export const ar: TranslationResources = { toggleBothSidebars: "تبديل كلا الشريطين الجانبيين", toggleSettings: "تبديل الإعدادات", toggleFocusMode: "تبديل وضع التركيز", + toggleExplorerPaneMaximization: "تبديل تكبير جزء المستكشف", cycleTheme: "موضوع الدورة", focusMessageInput: "التركيز على إدخال الرسالة", cycleAgentMode: "تبديل وضع الوكيل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 4fdd654fb..91ced0cab 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -590,6 +590,8 @@ export const en = { preparingTerminal: "Preparing terminal tab", preparingTerminalTooltip: "Preparing terminal...", newBrowser: "New browser", + maximizePane: "Maximize pane", + restorePane: "Restore pane", exitFocusMode: "Exit focus mode", splitRight: "Split pane right", splitDown: "Split pane down", @@ -1362,6 +1364,7 @@ export const en = { openPath: "Open path", }, branchSwitcher: { + triggerTooltip: "Switch workspace branch", currentBranch: "Current branch: {{branchName}}. Press to switch branch.", placeholder: "Switch branch...", searchPlaceholder: "Filter branches...", @@ -2117,6 +2120,7 @@ export const en = { toggleBothSidebars: "Toggle both sidebars", toggleSettings: "Toggle settings", toggleFocusMode: "Toggle focus mode", + toggleExplorerPaneMaximization: "Toggle Explorer pane maximization", cycleTheme: "Cycle theme", focusMessageInput: "Focus message input", cycleAgentMode: "Cycle agent mode", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 23fb61297..7fd41c150 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -597,6 +597,8 @@ export const es: TranslationResources = { preparingTerminal: "Preparando la pestaña del terminal", preparingTerminalTooltip: "Preparando terminal...", newBrowser: "Nuevo navegador", + maximizePane: "Maximizar panel", + restorePane: "Restaurar panel", exitFocusMode: "Salir del modo de concentración", splitRight: "Panel dividido a la derecha", splitDown: "Dividir panel hacia abajo", @@ -1393,6 +1395,7 @@ export const es: TranslationResources = { openPath: "Abrir ruta", }, branchSwitcher: { + triggerTooltip: "Cambiar rama del espacio de trabajo", currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", placeholder: "Cambiar de rama...", searchPlaceholder: "Filtrar ramas...", @@ -2101,6 +2104,7 @@ export const es: TranslationResources = { toggleBothSidebars: "Alternar ambas barras laterales", toggleSettings: "Alternar configuración", toggleFocusMode: "Alternar modo de enfoque", + toggleExplorerPaneMaximization: "Alternar maximización del panel Explorador", cycleTheme: "Tema del ciclo", focusMessageInput: "Entrada de mensaje de enfoque", cycleAgentMode: "Alternar modo del agente", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d312bcf50..19553dece 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -597,6 +597,8 @@ export const fr: TranslationResources = { preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminalTooltip: "Préparation du terminal...", newBrowser: "Nouveau navigateur", + maximizePane: "Agrandir le volet", + restorePane: "Restaurer le volet", exitFocusMode: "Quitter le mode concentration", splitRight: "Volet divisé à droite", splitDown: "Diviser le volet vers le bas", @@ -1396,6 +1398,7 @@ export const fr: TranslationResources = { openPath: "Ouvrir le chemin", }, branchSwitcher: { + triggerTooltip: "Changer la branche de l’espace de travail", currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", placeholder: "Changer de branche...", searchPlaceholder: "Filtrer les branches...", @@ -2105,6 +2108,7 @@ export const fr: TranslationResources = { toggleBothSidebars: "Basculer les deux barres latérales", toggleSettings: "Basculer les paramètres", toggleFocusMode: "Basculer le mode de mise au point", + toggleExplorerPaneMaximization: "Basculer l’agrandissement du volet Explorateur", cycleTheme: "Thème du cycle", focusMessageInput: "Saisie du message de focus", cycleAgentMode: "Parcourir les modes de l'agent", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c85c2aeda..4432c0c1a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -597,6 +597,8 @@ export const ja: TranslationResources = { preparingTerminal: "ターミナルタブを準備中", preparingTerminalTooltip: "ターミナルを準備中...", newBrowser: "新しいブラウザ", + maximizePane: "ペインを最大化", + restorePane: "ペインを元に戻す", exitFocusMode: "フォーカスモードを終了", splitRight: "右にペインを分割", splitDown: "下にペインを分割", @@ -1366,6 +1368,7 @@ export const ja: TranslationResources = { openPath: "パスを開く", }, branchSwitcher: { + triggerTooltip: "ワークスペースのブランチを切り替え", currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。", placeholder: "ブランチを切り替え...", searchPlaceholder: "ブランチをフィルタ...", @@ -2070,6 +2073,7 @@ export const ja: TranslationResources = { toggleBothSidebars: "両方のサイドバーを切り替え", toggleSettings: "設定を切り替え", toggleFocusMode: "フォーカスモードを切り替え", + toggleExplorerPaneMaximization: "エクスプローラーペインの最大化を切り替え", cycleTheme: "テーマを順に切り替え", focusMessageInput: "メッセージ入力にフォーカス", cycleAgentMode: "エージェントモードを順に切り替え", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 9273756bf..041270caa 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -594,6 +594,8 @@ export const ko: TranslationResources = { preparingTerminal: "터미널 탭 준비 중", preparingTerminalTooltip: "터미널 준비 중...", newBrowser: "새 브라우저", + maximizePane: "창 최대화", + restorePane: "창 복원", exitFocusMode: "집중 모드 종료", splitRight: "창을 오른쪽으로 분할", splitDown: "창을 아래로 분할", @@ -1362,6 +1364,7 @@ export const ko: TranslationResources = { openPath: "경로 열기", }, branchSwitcher: { + triggerTooltip: "워크스페이스 브랜치 전환", currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.", placeholder: "브랜치 전환...", searchPlaceholder: "브랜치 필터...", @@ -2064,6 +2067,7 @@ export const ko: TranslationResources = { toggleBothSidebars: "양쪽 사이드바 토글", toggleSettings: "설정 토글", toggleFocusMode: "집중 모드 토글", + toggleExplorerPaneMaximization: "탐색기 창 최대화 전환", cycleTheme: "테마 순환", focusMessageInput: "메시지 입력란에 포커스", cycleAgentMode: "에이전트 모드 전환", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 36244f611..c66576b70 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -596,6 +596,8 @@ export const ptBR: TranslationResources = { preparingTerminal: "Preparando aba de terminal", preparingTerminalTooltip: "Preparando terminal...", newBrowser: "Novo navegador", + maximizePane: "Maximizar painel", + restorePane: "Restaurar painel", exitFocusMode: "Sair do modo de foco", splitRight: "Dividir painel à direita", splitDown: "Dividir painel abaixo", @@ -1379,6 +1381,7 @@ export const ptBR: TranslationResources = { openPath: "Abrir caminho", }, branchSwitcher: { + triggerTooltip: "Trocar branch do workspace", currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.", placeholder: "Trocar branch...", searchPlaceholder: "Filtrar branches...", @@ -2084,6 +2087,7 @@ export const ptBR: TranslationResources = { toggleBothSidebars: "Alternar ambas as barras laterais", toggleSettings: "Alternar Configurações", toggleFocusMode: "Alternar modo de foco", + toggleExplorerPaneMaximization: "Alternar maximização do painel Explorador", cycleTheme: "Alternar tema", focusMessageInput: "Focar entrada de mensagem", cycleAgentMode: "Alternar modo do agente", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f878ca4ca..d044cdf51 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -596,6 +596,8 @@ export const ru: TranslationResources = { preparingTerminal: "Подготовка вкладки терминала", preparingTerminalTooltip: "Подготовка терминала...", newBrowser: "Новый браузер", + maximizePane: "Развернуть панель", + restorePane: "Восстановить панель", exitFocusMode: "Выйти из режима фокусировки", splitRight: "Разделить панель справа", splitDown: "Разделить панель вниз", @@ -1382,6 +1384,7 @@ export const ru: TranslationResources = { openPath: "Открыть путь", }, branchSwitcher: { + triggerTooltip: "Переключить ветку рабочего пространства", currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.", placeholder: "Сменить ветку...", searchPlaceholder: "Фильтровать ветки...", @@ -2092,6 +2095,7 @@ export const ru: TranslationResources = { toggleBothSidebars: "Переключить обе боковые панели", toggleSettings: "Переключить настройки", toggleFocusMode: "Переключить режим фокусировки", + toggleExplorerPaneMaximization: "Переключить разворачивание панели Проводника", cycleTheme: "Циклическая тема", focusMessageInput: "Фокус ввода сообщения", cycleAgentMode: "Переключить режим агента", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 03ef8359b..af111f1d9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -592,6 +592,8 @@ export const zhCN: TranslationResources = { preparingTerminal: "正在准备 Terminal 标签", preparingTerminalTooltip: "正在准备 Terminal...", newBrowser: "新建浏览器", + maximizePane: "最大化窗格", + restorePane: "还原窗格", exitFocusMode: "退出专注模式", splitRight: "向右拆分窗格", splitDown: "向下拆分窗格", @@ -1336,6 +1338,7 @@ export const zhCN: TranslationResources = { openPath: "打开路径", }, branchSwitcher: { + triggerTooltip: "切换工作区分支", currentBranch: "当前分支:{{branchName}}。按下以切换分支。", placeholder: "切换分支...", searchPlaceholder: "筛选分支...", @@ -2029,6 +2032,7 @@ export const zhCN: TranslationResources = { toggleBothSidebars: "切换两侧边栏", toggleSettings: "切换设置", toggleFocusMode: "切换专注模式", + toggleExplorerPaneMaximization: "切换资源管理器窗格最大化", cycleTheme: "循环切换主题", focusMessageInput: "聚焦消息输入框", cycleAgentMode: "循环切换代理模式", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 5a0d86f84..803a9952d 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -19,7 +19,11 @@ export type MessageInputKeyboardActionKind = export type KeyboardActionId = | "agent.interrupt" | "agent.new" - | "workspace.tab.new" + | "workspace.tab.menu.open" + | "workspace.tab.target.agent" + | "workspace.tab.target.browser" + | "workspace.tab.target.changes" + | "workspace.tab.target.files" | "workspace.tab.close.current" | "workspace.tab.navigate.index" | "workspace.tab.navigate.relative" @@ -34,6 +38,7 @@ export type KeyboardActionId = | "workspace.pane.move-tab.up" | "workspace.pane.move-tab.down" | "workspace.pane.close" + | "workspace.explorer.maximize.toggle" | "workspace.navigate.index" | "workspace.navigate.relative" | "sidebar.toggle.left" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher-context.tsx b/packages/app/src/keyboard/keyboard-action-dispatcher-context.tsx new file mode 100644 index 000000000..9a45a9d4f --- /dev/null +++ b/packages/app/src/keyboard/keyboard-action-dispatcher-context.tsx @@ -0,0 +1,28 @@ +import { createContext, type ReactNode, useContext, useRef } from "react"; + +import { createKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; + +type KeyboardActionDispatcher = ReturnType; + +const KeyboardActionDispatcherContext = createContext(null); + +export function KeyboardActionDispatcherProvider({ children }: { children: ReactNode }) { + const dispatcherRef = useRef(null); + if (!dispatcherRef.current) { + dispatcherRef.current = createKeyboardActionDispatcher(); + } + + return ( + + {children} + + ); +} + +export function useKeyboardActionDispatcher(): KeyboardActionDispatcher { + const dispatcher = useContext(KeyboardActionDispatcherContext); + if (!dispatcher) { + throw new Error("useKeyboardActionDispatcher must be used within its provider"); + } + return dispatcher; +} diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts index 0f377ea50..c5fc53745 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.test.ts @@ -186,4 +186,34 @@ describe("keyboard-action-dispatcher", () => { expect(handled).toBe(false); expect(handle).not.toHaveBeenCalled(); }); + + it("does not let an old registration remove its same-id replacement", () => { + const oldHandle = vi.fn(() => true); + const replacementHandle = vi.fn(() => true); + const action: KeyboardActionDefinition = { + id: "workspace.tab.target.agent", + scope: "workspace", + }; + + const unregisterOld = dispatcher.registerHandler({ + handlerId: "workspace-new-tab-menu:server:pane", + actions: [action.id], + enabled: true, + priority: 200, + handle: oldHandle, + }); + dispatcher.registerHandler({ + handlerId: "workspace-new-tab-menu:server:pane", + actions: [action.id], + enabled: true, + priority: 200, + handle: replacementHandle, + }); + + unregisterOld(); + + expect(dispatcher.dispatch(action)).toBe(true); + expect(replacementHandle).toHaveBeenCalledOnce(); + expect(oldHandle).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index e085190f4..bd7739cca 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -10,7 +10,12 @@ export type KeyboardActionId = | "message-input.voice-toggle" | "message-input.voice-mute-toggle" | "message-input.mode-cycle" - | "workspace.tab.new" + | "workspace.agent.new" + | "workspace.tab.menu.open" + | "workspace.tab.target.agent" + | "workspace.tab.target.browser" + | "workspace.tab.target.changes" + | "workspace.tab.target.files" | "workspace.tab.close-current" | "workspace.tab.navigate-index" | "workspace.tab.navigate-relative" @@ -25,6 +30,7 @@ export type KeyboardActionId = | "workspace.pane.move-tab.up" | "workspace.pane.move-tab.down" | "workspace.pane.close" + | "workspace.explorer.maximize.toggle" | "workspace.focus.toggle" | "workspace.terminal.new" | "workspace.browser.new" @@ -45,7 +51,12 @@ export type KeyboardActionDefinition = | { id: "message-input.voice-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "message-input.mode-cycle"; scope: KeyboardActionScope } - | { id: "workspace.tab.new"; scope: KeyboardActionScope } + | { id: "workspace.agent.new"; scope: KeyboardActionScope } + | { id: "workspace.tab.menu.open"; scope: KeyboardActionScope } + | { id: "workspace.tab.target.agent"; scope: KeyboardActionScope } + | { id: "workspace.tab.target.browser"; scope: KeyboardActionScope } + | { id: "workspace.tab.target.changes"; scope: KeyboardActionScope } + | { id: "workspace.tab.target.files"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } @@ -60,6 +71,7 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; scope: KeyboardActionScope } + | { id: "workspace.explorer.maximize.toggle"; scope: KeyboardActionScope } | { id: "workspace.focus.toggle"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "workspace.browser.new"; scope: KeyboardActionScope } @@ -89,14 +101,15 @@ export function createKeyboardActionDispatcher() { return { registerHandler(handler: KeyboardActionHandler) { - handlers.set(handler.handlerId, { + const entry: KeyboardActionRegistryEntry = { ...handler, registeredAt: nextRegistrationOrder++, - }); + }; + handlers.set(handler.handlerId, entry); return () => { const current = handlers.get(handler.handlerId); - if (!current) { + if (current !== entry) { return; } handlers.delete(handler.handlerId); @@ -125,5 +138,3 @@ export function createKeyboardActionDispatcher() { }, }; } - -export const keyboardActionDispatcher = createKeyboardActionDispatcher(); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 9a88975d2..e5f755f07 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -226,7 +226,7 @@ describe("keyboard-shortcuts", () => { name: "matches Mod+T to open new tab", event: { key: "t", code: "KeyT", metaKey: true }, context: { isMac: true }, - action: "workspace.tab.new", + action: "workspace.tab.menu.open", }, { name: "matches Alt+Shift+W to close current tab on web", @@ -270,6 +270,18 @@ describe("keyboard-shortcuts", () => { context: { isMac: true }, action: "workspace.pane.split.down", }, + { + name: "matches Cmd+Shift+M to maximize the Explorer pane on macOS", + event: { key: "M", code: "KeyM", metaKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.explorer.maximize.toggle", + }, + { + name: "matches Ctrl+Shift+M to maximize the Explorer pane on non-macOS", + event: { key: "M", code: "KeyM", ctrlKey: true, shiftKey: true }, + context: { isMac: false }, + action: "workspace.explorer.maximize.toggle", + }, { name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS", event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true }, @@ -589,7 +601,7 @@ describe("keyboard-shortcuts", () => { expectShortcutResolution({ event: { key: "t", code: "KeyT", ctrlKey: true }, context: { isDesktop: true, focusScope: "browser" }, - action: "workspace.tab.new", + action: "workspace.tab.menu.open", }); }); @@ -642,6 +654,7 @@ describe("keyboard-shortcut help sections", () => { "workspace-tab-close-current": ["alt", "shift", "W"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], + "workspace-explorer-maximize": ["mod", "shift", "M"], "cycle-agent-mode": ["shift", "Tab"], }, }, @@ -661,6 +674,7 @@ describe("keyboard-shortcut help sections", () => { "workspace-tab-close-current": ["mod", "W"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], + "workspace-explorer-maximize": ["mod", "shift", "M"], }, }, { @@ -669,6 +683,7 @@ describe("keyboard-shortcut help sections", () => { expectedKeys: { "workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-close-current": ["ctrl", "W"], + "workspace-explorer-maximize": ["ctrl", "shift", "M"], }, }, { @@ -993,7 +1008,7 @@ describe("unassigned shortcuts", () => { bindings, }); - expect(result.match?.action).toBe("workspace.tab.new"); + expect(result.match?.action).toBe("workspace.tab.menu.open"); }); it("treats a stored empty combo as unassigned too", () => { @@ -1017,7 +1032,7 @@ describe("unassigned shortcuts", () => { context: desktopNonMac, bindings, }).match?.action, - ).toBe("workspace.tab.new"); + ).toBe("workspace.tab.menu.open"); expect( resolveShortcut({ event: { key: "t", code: "KeyT", ctrlKey: true }, @@ -1038,7 +1053,7 @@ describe("unassigned shortcuts", () => { bindings, }); - expect(result.match?.action).toBe("workspace.tab.new"); + expect(result.match?.action).toBe("workspace.tab.menu.open"); }); }); @@ -1137,3 +1152,62 @@ describe("unassigned shortcuts", () => { }); }); }); + +describe("direct new-tab target shortcuts", () => { + const desktopNonMac = { isMac: false, isDesktop: true }; + const targetCases = [ + ["a", "KeyA", "workspace.tab.target.agent"], + ["b", "KeyB", "workspace.tab.target.browser"], + ["c", "KeyC", "workspace.tab.target.changes"], + ["e", "KeyE", "workspace.tab.target.files"], + ] as const; + + it("leaves bare letters to the open menu", () => { + const result = resolveShortcut({ + event: { key: "a", code: "KeyA" }, + context: { ...desktopNonMac, focusScope: "other" }, + bindings: buildEffectiveBindings({}), + }); + + expect(result.match).toBeNull(); + }); + + it.each(targetCases)("routes Ctrl+Shift+%s directly to %s", (key, code, action) => { + const result = resolveShortcut({ + event: { key, code, ctrlKey: true, shiftKey: true }, + context: { ...desktopNonMac, focusScope: "other" }, + bindings: buildEffectiveBindings({}), + }); + expect(result.match?.action).toBe(action); + }); + + it.each(targetCases)("routes Cmd+Shift+%s directly to %s", (key, code, action) => { + const result = resolveShortcut({ + event: { key, code, metaKey: true, shiftKey: true }, + context: { isMac: true, isDesktop: true, focusScope: "other" }, + bindings: buildEffectiveBindings({}), + }); + expect(result.match?.action).toBe(action); + }); + + it("uses the existing override map for target matching and display", () => { + const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac"; + const overrides = { [bindingId]: "Ctrl+Shift+G" }; + const rebound = resolveShortcut({ + event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true }, + context: { ...desktopNonMac, focusScope: "other" }, + bindings: buildEffectiveBindings(overrides), + }); + const original = resolveShortcut({ + event: { key: "a", code: "KeyA", ctrlKey: true, shiftKey: true }, + context: { ...desktopNonMac, focusScope: "other" }, + bindings: buildEffectiveBindings(overrides), + }); + + expect(rebound.match?.action).toBe("workspace.tab.target.agent"); + expect(original.match).toBeNull(); + expect( + resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac), + ).toEqual([["ctrl", "shift", "G"]]); + }); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6653e412d..650fd8980 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -171,7 +171,11 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = { "switch-project": "settings.shortcuts.help.switchProject", "archive-workspace": "settings.shortcuts.help.archiveWorkspace", "workspace-tab-new": "settings.shortcuts.help.newTab", + "workspace-tab-target-agent": "workspace.tabs.actions.newAgent", + "workspace-tab-target-browser": "workspace.tabs.actions.newBrowser", + "workspace-tab-target-changes": "workspace.tabs.actions.changes", + "workspace-tab-target-files": "workspace.tabs.actions.files", "workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab", "workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace", "workspace-tab-jump-index": "settings.shortcuts.help.jumpToTab", @@ -223,6 +232,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record = { "workspace-pane-move-tab-up": "settings.shortcuts.help.moveTabUp", "workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown", "workspace-pane-close": "settings.shortcuts.help.closePane", + "workspace-explorer-maximize": "settings.shortcuts.help.toggleExplorerPaneMaximization", "workspace-terminal-new": "settings.shortcuts.help.newTerminal", "search-files": "settings.shortcuts.help.searchFiles", "toggle-command-center": "settings.shortcuts.help.toggleCommandCenter", @@ -379,7 +389,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ // --- Tab management --- { id: "workspace-tab-new-cmd-t-mac", - action: "workspace.tab.new", + action: "workspace.tab.menu.open", combo: "Cmd+T", when: { mac: true, commandCenter: false }, help: { @@ -390,7 +400,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, { id: "workspace-tab-new-ctrl-t-non-mac", - action: "workspace.tab.new", + action: "workspace.tab.menu.open", combo: "Ctrl+T", when: { mac: false, commandCenter: false, terminal: false }, help: { @@ -399,6 +409,94 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ label: "New tab", }, }, + { + id: "workspace-tab-target-agent-cmd-shift-a-mac", + action: "workspace.tab.target.agent", + combo: "Cmd+Shift+A", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-tab-target-agent", + section: "tabs-panes", + label: "New agent", + }, + }, + { + id: "workspace-tab-target-agent-ctrl-shift-a-non-mac", + action: "workspace.tab.target.agent", + combo: "Ctrl+Shift+A", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-target-agent", + section: "tabs-panes", + label: "New agent", + }, + }, + { + id: "workspace-tab-target-browser-cmd-shift-b-mac", + action: "workspace.tab.target.browser", + combo: "Cmd+Shift+B", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-tab-target-browser", + section: "tabs-panes", + label: "New browser", + }, + }, + { + id: "workspace-tab-target-browser-ctrl-shift-b-non-mac", + action: "workspace.tab.target.browser", + combo: "Ctrl+Shift+B", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-target-browser", + section: "tabs-panes", + label: "New browser", + }, + }, + { + id: "workspace-tab-target-changes-cmd-shift-c-mac", + action: "workspace.tab.target.changes", + combo: "Cmd+Shift+C", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-tab-target-changes", + section: "tabs-panes", + label: "Changes", + }, + }, + { + id: "workspace-tab-target-changes-ctrl-shift-c-non-mac", + action: "workspace.tab.target.changes", + combo: "Ctrl+Shift+C", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-target-changes", + section: "tabs-panes", + label: "Changes", + }, + }, + { + id: "workspace-tab-target-files-cmd-shift-e-mac", + action: "workspace.tab.target.files", + combo: "Cmd+Shift+E", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-tab-target-files", + section: "tabs-panes", + label: "Files", + }, + }, + { + id: "workspace-tab-target-files-ctrl-shift-e-non-mac", + action: "workspace.tab.target.files", + combo: "Ctrl+Shift+E", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-target-files", + section: "tabs-panes", + label: "Files", + }, + }, { id: "workspace-tab-close-current-cmd-w-mac", action: "workspace.tab.close.current", @@ -737,6 +835,28 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ label: "Close pane", }, }, + { + id: "workspace-explorer-maximize-cmd-shift-m-mac", + action: "workspace.explorer.maximize.toggle", + combo: "Cmd+Shift+M", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-explorer-maximize", + section: "tabs-panes", + label: "Toggle Explorer pane maximization", + }, + }, + { + id: "workspace-explorer-maximize-ctrl-shift-m-non-mac", + action: "workspace.explorer.maximize.toggle", + combo: "Ctrl+Shift+M", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-explorer-maximize", + section: "tabs-panes", + label: "Toggle Explorer pane maximization", + }, + }, // --- New terminal --- { diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 494c2a188..db9ccebb3 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -28,7 +28,11 @@ function makeCtx(overrides: Partial = {}): ShortcutRouti describe("routeKeyboardShortcut — dispatch passthroughs", () => { it.each([ ["agent.interrupt", { id: "agent.interrupt", scope: "global" }], - ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }], + ["workspace.tab.menu.open", { id: "workspace.tab.menu.open", scope: "workspace" }], + ["workspace.tab.target.agent", { id: "workspace.tab.target.agent", scope: "workspace" }], + ["workspace.tab.target.browser", { id: "workspace.tab.target.browser", scope: "workspace" }], + ["workspace.tab.target.changes", { id: "workspace.tab.target.changes", scope: "workspace" }], + ["workspace.tab.target.files", { id: "workspace.tab.target.files", scope: "workspace" }], ["workspace.new", { id: "workspace.new", scope: "sidebar" }], ["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }], ["workspace.archive", { id: "workspace.archive", scope: "sidebar" }], @@ -48,6 +52,10 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { ["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }], ["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }], ["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }], + [ + "workspace.explorer.maximize.toggle", + { id: "workspace.explorer.maximize.toggle", scope: "workspace" }, + ], ["view.toggle.focus", { id: "workspace.focus.toggle", scope: "workspace" }], ])("%s → dispatch %j", (action, expected) => { expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({ diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 0fd622d30..72c3763a8 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -40,7 +40,11 @@ const NONE: ShortcutAction = { kind: "none" }; // Action ids whose routing is a no-payload pass-through to the dispatcher. const PASSTHROUGH_DISPATCH: Record = { "agent.interrupt": { id: "agent.interrupt", scope: "global" }, - "workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" }, + "workspace.tab.menu.open": { id: "workspace.tab.menu.open", scope: "workspace" }, + "workspace.tab.target.agent": { id: "workspace.tab.target.agent", scope: "workspace" }, + "workspace.tab.target.browser": { id: "workspace.tab.target.browser", scope: "workspace" }, + "workspace.tab.target.changes": { id: "workspace.tab.target.changes", scope: "workspace" }, + "workspace.tab.target.files": { id: "workspace.tab.target.files", scope: "workspace" }, "workspace.new": { id: "workspace.new", scope: "sidebar" }, "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, @@ -60,6 +64,10 @@ const PASSTHROUGH_DISPATCH: Record = { "workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" }, "workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" }, "workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" }, + "workspace.explorer.maximize.toggle": { + id: "workspace.explorer.maximize.toggle", + scope: "workspace", + }, "view.toggle.focus": { id: "workspace.focus.toggle", scope: "workspace" }, }; diff --git a/packages/app/src/keyboard/shortcut-string.test.ts b/packages/app/src/keyboard/shortcut-string.test.ts index f7e5c70df..e7cffee59 100644 --- a/packages/app/src/keyboard/shortcut-string.test.ts +++ b/packages/app/src/keyboard/shortcut-string.test.ts @@ -87,9 +87,7 @@ describe("comboStringToShortcutKeys", () => { it("hands named keys to the display table under the names it uses", () => { expect(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft")).toEqual(["mod", "shift", "Left"]); - expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe( - "Shift+⌘+←", - ); + expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe("⇧⌘←"); expect(formatShortcut(comboStringToShortcutKeys("Escape"), "mac")).toBe("Esc"); expect(formatShortcut(comboStringToShortcutKeys("Backspace"), "mac")).toBe("⌫"); }); diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 71ea30af1..784413cd7 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -122,7 +122,10 @@ function getFocusableElements(scope: HTMLElement): HTMLElement[] { } function focusFirstElement(scope: HTMLElement): void { - const first = getFocusableElements(scope)[0]; + const firstMenuItem = scope.querySelector( + '[data-menu-item="true"]:not([data-menu-disabled="true"])', + ); + const first = firstMenuItem ?? getFocusableElements(scope)[0]; (first ?? scope).focus(); } @@ -144,10 +147,14 @@ function handleWebOverlayFocus(event: FocusEvent): void { }); } -function handleWebOverlayKeyDown(event: KeyboardEvent): void { +/** + * Gives the top painted overlay first refusal on a key before app-wide shortcuts run. + * The window listener below is the fallback for keys nobody routes through the shortcut engine. + */ +export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean { const top = getTopWebOverlay(); const scope = top?.getScope(); - if (!top || !scope) return; + if (!top || !scope) return false; if (event.key === "Tab") { const focusable = getFocusableElements(scope); @@ -161,12 +168,17 @@ function handleWebOverlayKeyDown(event: KeyboardEvent): void { event.stopImmediatePropagation(); (shouldWrapBackward ? last : first)?.focus(); if (!first) scope.focus(); - return; + return true; } } - if (!top.getKeyHandler()(event)) return; + if (!top.getKeyHandler()(event)) return false; event.stopImmediatePropagation(); + return true; +} + +function handleWebOverlayKeyDown(event: KeyboardEvent): void { + dispatchTopWebOverlayKeyDown(event); } function attachWebOverlayListeners(): void { @@ -209,6 +221,7 @@ interface WebOverlayRegistration { active: boolean; layer: number; onKeyDown: WebOverlayKeyHandler; + restoreFocusRef?: React.RefObject; } /** @@ -216,12 +229,17 @@ interface WebOverlayRegistration { * Only the highest painted overlay receives keyboard input; focus is trapped * there and restored to the opener when that overlay closes. */ -export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverlayRegistration) { +export function useWebOverlayRegistration({ + active, + layer, + onKeyDown, + restoreFocusRef: preferredRestoreFocusRef, +}: WebOverlayRegistration) { const idRef = useRef(Symbol("web-overlay")); const scopeRef = useRef(null); const layerRef = useRef(layer); const keyHandlerRef = useRef(onKeyDown); - const restoreFocusRef = useRef(null); + const capturedRestoreFocusRef = useRef(null); const removeEntryRef = useRef<(() => void) | null>(null); const activeRef = useRef(active); const wasActiveRef = useRef(false); @@ -230,8 +248,13 @@ export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverl layerRef.current = layer; keyHandlerRef.current = onKeyDown; if (active && !wasActiveRef.current && typeof document !== "undefined") { - restoreFocusRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; + const requestedRestoreTarget = preferredRestoreFocusRef?.current; + if (requestedRestoreTarget instanceof HTMLElement) { + capturedRestoreFocusRef.current = requestedRestoreTarget; + } else { + capturedRestoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } } wasActiveRef.current = active; @@ -254,7 +277,7 @@ export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverl getLayer: () => layerRef.current, getScope: () => scopeRef.current, getKeyHandler: () => keyHandlerRef.current, - restoreFocus: restoreFocusRef.current, + restoreFocus: capturedRestoreFocusRef.current, }; removeEntryRef.current = addWebOverlay(entry); }, []); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 6c401316a..2bc8c5291 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -2,6 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -11,7 +12,6 @@ import React, { } from "react"; import { Pressable, - ScrollView, Text, View, type LayoutChangeEvent, @@ -28,12 +28,21 @@ import { FileDiff, FolderTree, GitPullRequest, + Maximize2, + Minimize2, Plus, SquarePen, SquareTerminal, X, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import Animated, { + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + type AnimatedStyle, +} from "react-native-reanimated"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { useTranslation } from "react-i18next"; import { useRouter, type Href } from "expo-router"; import { SortableInlineList } from "@/components/sortable-inline-list"; @@ -61,6 +70,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout"; @@ -93,13 +103,16 @@ import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; // Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes // shape without changing them mis-measures and drops the row into the overflow-scroll fallback at // the wrong width. Keep them together. -const TAB_CHIP_HEIGHT = 26; +// Tabs and the adjacent New Tab trigger are one control family. Keep their outer box and corner +// token identical; only their horizontal sizing differs (content-width chip versus square icon). const TAB_CHIP_HORIZONTAL_PADDING = 8; const TAB_CHIP_GAP = 4; const TAB_ROW_PADDING_HORIZONTAL = 4; @@ -110,10 +123,68 @@ const TAB_MIN_WIDTH = 96; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; +const TAB_SCROLL_SHADE_WIDTH = 24; +const TAB_SCROLL_EDGE_EPSILON = 1; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); const ThemedCopy = withUnistyles(Copy); + +function WorkspaceTabScrollShadeSvg({ side, color }: { side: "left" | "right"; color: string }) { + const gradientId = `workspace-tab-scroll-shade-${side}-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + + + + + + + ); +} + +const ThemedWorkspaceTabScrollShadeSvg = withUnistyles(WorkspaceTabScrollShadeSvg); +const tabScrollShadeColorMapping = (theme: Theme) => ({ color: theme.colors.surface0 }); + +function WorkspaceTabScrollShades({ + visible, + leftStyle, + rightStyle, +}: { + visible: boolean; + leftStyle: AnimatedStyle<{ opacity: number }>; + rightStyle: AnimatedStyle<{ opacity: number }>; +}) { + if (!visible) return null; + + return ( + <> + + + + + + + + ); +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); @@ -126,6 +197,8 @@ const ThemedPlus = withUnistyles(Plus); const ThemedFileDiff = withUnistyles(FileDiff); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const ThemedMaximize2 = withUnistyles(Maximize2); +const ThemedMinimize2 = withUnistyles(Minimize2); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); @@ -141,8 +214,15 @@ const CHANGES_TARGET = { kind: "working_diff" } as const; const FILES_TARGET = { kind: "files" } as const; const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; -function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { - return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; +function inlineAddActionButtonStyle({ + hovered, + pressed, + open = false, +}: PressableStateCallbackType & { open?: boolean }) { + return [ + styles.inlineAddActionButton, + (hovered || pressed || open) && styles.newTabActionButtonHovered, + ]; } function updateMeasuredWidth( @@ -210,6 +290,8 @@ interface TabTargetLauncherOptions { } interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { + open: boolean; + onOpenChange: (open: boolean) => void; shortcutKeys: ShortcutKey[][] | null; onEditProfiles: () => void; showCreateBrowserTab: boolean; @@ -220,6 +302,8 @@ interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { } function WorkspaceNewTabButton({ + open, + onOpenChange, shortcutKeys, onCreateAgentTab, onCreateTerminal, @@ -242,15 +326,36 @@ function WorkspaceNewTabButton({ () => resolveTerminalProfiles(config?.terminalProfiles), [config?.terminalProfiles], ); - const shortcut = useMemo( - () => (shortcutKeys ? : undefined), - [shortcutKeys], - ); + const agentKeys = useShortcutKeys("workspace-tab-target-agent"); + const terminalKeys = useShortcutKeys("workspace-terminal-new"); + const browserKeys = useShortcutKeys("workspace-tab-target-browser"); + const changesKeys = useShortcutKeys("workspace-tab-target-changes"); + const filesKeys = useShortcutKeys("workspace-tab-target-files"); const tooltipText = t("workspace.tabs.actions.newTab"); + const agentShortcut = useMemo( + () => (agentKeys ? : undefined), + [agentKeys], + ); + const terminalShortcut = useMemo( + () => (terminalKeys ? : undefined), + [terminalKeys], + ); + const browserShortcut = useMemo( + () => (browserKeys ? : undefined), + [browserKeys], + ); + const changesShortcut = useMemo( + () => (changesKeys ? : undefined), + [changesKeys], + ); + const filesShortcut = useMemo( + () => (filesKeys ? : undefined), + [filesKeys], + ); return ( - + - {tooltipText} + + {tooltipText} + {shortcutKeys ? : null} + {t("workspace.tabs.actions.newAgent")} @@ -279,6 +387,7 @@ function WorkspaceNewTabButton({ testID="workspace-new-tab-menu-terminal" leading={TERMINAL_ICON} disabled={terminalDisabled} + trailing={terminalShortcut} onSelect={terminalDisabled ? undefined : onCreateTerminal} > {t("workspace.tabs.actions.newTerminal")} @@ -287,6 +396,7 @@ function WorkspaceNewTabButton({ {t("workspace.tabs.actions.newBrowser")} @@ -296,6 +406,7 @@ function WorkspaceNewTabButton({ {t("workspace.tabs.actions.changes")} @@ -304,6 +415,7 @@ function WorkspaceNewTabButton({ {t("workspace.tabs.actions.files")} @@ -337,6 +449,99 @@ function WorkspaceNewTabButton({ ); } +function WorkspacePaneMaximizeButton({ + visible, + maximized, + onPress, + onLayout, +}: { + visible: boolean; + maximized: boolean; + onPress?: () => void; + onLayout: (event: LayoutChangeEvent) => void; +}) { + const { t } = useTranslation(); + const maximizePaneKeys = useShortcutKeys("workspace-explorer-maximize"); + if (!visible || !onPress) { + return null; + } + const label = t( + maximized ? "workspace.tabs.actions.restorePane" : "workspace.tabs.actions.maximizePane", + ); + + return ( + + + + {maximized ? ( + + ) : ( + + )} + + + + {label} + {maximizePaneKeys ? : null} + + + + + ); +} + +function WorkspaceExitFocusModeButton({ + visible, + onPress, + onLayout, +}: { + visible: boolean; + onPress: () => void; + onLayout: (event: LayoutChangeEvent) => void; +}) { + const { t } = useTranslation(); + const focusModeKeys = useShortcutKeys("toggle-focus"); + if (!visible) { + return null; + } + + return ( + + + + + + + + + {t("workspace.tabs.actions.exitFocusMode")} + + {focusModeKeys ? ( + + ) : null} + + + + + ); +} + +function resolvePaneMaximizeReservedWidth(visible: boolean, measuredWidth: number): number { + return visible ? measuredWidth : 0; +} + function TabContextMenuItem({ entry, }: { @@ -464,6 +669,9 @@ interface WorkspaceDesktopTabsRowProps { externalDndContext?: boolean; activeDragTabId?: string | null; tabDropPreviewIndex?: number | null; + showPaneMaximizeAction?: boolean; + paneMaximized?: boolean; + onTogglePaneMaximized?: () => void; focusModeEnabled: boolean; onExitFocusMode: () => void; } @@ -942,16 +1150,23 @@ function ResolvedWorkspaceDesktopTabsRow({ externalDndContext = false, activeDragTabId = null, tabDropPreviewIndex = null, + showPaneMaximizeAction = false, + paneMaximized = false, + onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); const router = useRouter(); const newTabKeys = useShortcutKeys("workspace-tab-new"); - const focusModeKeys = useShortcutKeys("toggle-focus"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); + const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); + const [newTabMenuOpen, setNewTabMenuOpen] = useState(false); + const tabScrollOffset = useSharedValue(0); + const tabScrollViewportWidth = useSharedValue(0); + const tabScrollContentWidth = useSharedValue(0); const labelMeasurementsRef = useRef(new Map()); const [trackSnapshot, setTrackSnapshot] = useState(null); const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; @@ -980,13 +1195,48 @@ function ResolvedWorkspaceDesktopTabsRow({ updateMeasuredWidth(setExitFocusModeWidth, event); }, []); + const handlePaneMaximizeButtonLayout = useCallback((event: LayoutChangeEvent) => { + updateMeasuredWidth(setPaneMaximizeButtonWidth, event); + }, []); + + const handleTabScrollLayout = useCallback( + (event: LayoutChangeEvent) => { + tabScrollViewportWidth.value = event.nativeEvent.layout.width; + }, + [tabScrollViewportWidth], + ); + + const handleTabScrollContentSizeChange = useCallback( + (width: number) => { + tabScrollContentWidth.value = width; + }, + [tabScrollContentWidth], + ); + + const handleTabScroll = useAnimatedScrollHandler((event) => { + tabScrollOffset.value = event.contentOffset.x; + tabScrollViewportWidth.value = event.layoutMeasurement.width; + tabScrollContentWidth.value = event.contentSize.width; + }); + + const leftTabScrollShadeStyle = useAnimatedStyle(() => ({ + opacity: Number(tabScrollOffset.value > TAB_SCROLL_EDGE_EPSILON), + })); + const rightTabScrollShadeStyle = useAnimatedStyle(() => ({ + opacity: Number( + tabScrollOffset.value + tabScrollViewportWidth.value < + tabScrollContentWidth.value - TAB_SCROLL_EDGE_EPSILON, + ), + })); + const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, actionsReservedWidth: Math.max( 0, (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + - (focusModeEnabled ? exitFocusModeWidth : 0), + (focusModeEnabled ? exitFocusModeWidth : 0) + + resolvePaneMaximizeReservedWidth(showPaneMaximizeAction, paneMaximizeButtonWidth), ), rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, tabGap: TAB_CHIP_GAP, @@ -997,7 +1247,13 @@ function ResolvedWorkspaceDesktopTabsRow({ tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), - [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth], + [ + exitFocusModeWidth, + focusModeEnabled, + inlineAddButtonWidth, + paneMaximizeButtonWidth, + showPaneMaximizeAction, + ], ); const fallbackTabLabels = useMemo( @@ -1175,6 +1431,67 @@ function ResolvedWorkspaceDesktopTabsRow({ const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; + useEffect(() => { + if (!isFocused) { + setNewTabMenuOpen(false); + } + }, [isFocused]); + + const handleNewTabKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!isFocused) return false; + if (action.id === "workspace.tab.menu.open") { + setNewTabMenuOpen(true); + return true; + } + switch (action.id) { + case "workspace.tab.target.agent": + setNewTabMenuOpen(false); + handleCreateAgentTab(); + return true; + case "workspace.tab.target.browser": + if (!showCreateBrowserTab) return true; + setNewTabMenuOpen(false); + handleCreateBrowser(); + return true; + case "workspace.tab.target.changes": + if (!isGit) return true; + setNewTabMenuOpen(false); + handleOpenChanges(); + return true; + case "workspace.tab.target.files": + setNewTabMenuOpen(false); + handleOpenFiles(); + return true; + default: + return false; + } + }, + [ + handleCreateAgentTab, + handleCreateBrowser, + handleOpenChanges, + handleOpenFiles, + isFocused, + isGit, + showCreateBrowserTab, + ], + ); + + useKeyboardActionHandler({ + handlerId: `workspace-new-tab-menu:${normalizedServerId}:${paneId ?? "main"}`, + actions: [ + "workspace.tab.menu.open", + "workspace.tab.target.agent", + "workspace.tab.target.browser", + "workspace.tab.target.changes", + "workspace.tab.target.files", + ], + enabled: isFocused, + priority: 200, + handle: handleNewTabKeyboardAction, + }); + const renderTab = useCallback( ({ item, @@ -1276,72 +1593,67 @@ function ResolvedWorkspaceDesktopTabsRow({ /> ))} - {focusModeEnabled ? ( - - - - - - - - - {t("workspace.tabs.actions.exitFocusMode")} - - {focusModeKeys ? ( - - ) : null} - - - - - ) : null} - - - {!layout.requiresHorizontalScrollFallback ? ( - + + + - ) : null} - + {!layout.requiresHorizontalScrollFallback ? ( + + ) : null} + + + {layout.requiresHorizontalScrollFallback ? ( ) : null} + ); @@ -1502,6 +1820,11 @@ const styles = StyleSheet.create((theme) => ({ tabsScroll: { minWidth: 0, }, + tabsScrollContainer: { + minWidth: 0, + flex: 1, + alignSelf: "stretch", + }, tabsScrollFitContent: { flex: 1, }, @@ -1513,6 +1836,18 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, }, + tabScrollShade: { + position: "absolute", + top: 0, + bottom: 1, + width: TAB_SCROLL_SHADE_WIDTH, + }, + tabScrollShadeLeft: { + left: 0, + }, + tabScrollShadeRight: { + right: 0, + }, exitFocusModeSlot: { alignSelf: "stretch", flexDirection: "row", @@ -1526,8 +1861,12 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingHorizontal: theme.spacing[1], }, + paneMaximizeButtonSlot: { + // Keep the glyph centered in its hit target while placing that glyph on the trailing rail. + marginRight: theme.spacing[2], + }, tab: { - height: TAB_CHIP_HEIGHT, + height: buttonControlHeight.xs, paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING, borderRadius: theme.borderRadius.md, flexDirection: "row", @@ -1661,11 +2000,13 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.foregroundMuted, }, inlineAddActionButton: { - width: 28, - height: 28, + width: buttonControlHeight.xs, + height: buttonControlHeight.xs, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", + outlineWidth: 0, + outlineColor: "transparent", }, newTabActionButtonHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 17e50ef0d..5c1892a7e 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3147,7 +3147,7 @@ function WorkspaceScreenContent({ const handleWorkspaceTabAction = useCallback( (action: KeyboardActionDefinition): boolean => { switch (action.id) { - case "workspace.tab.new": + case "workspace.agent.new": handleCreateDraftTab(); return true; case "workspace.terminal.new": @@ -3298,7 +3298,7 @@ function WorkspaceScreenContent({ useKeyboardActionHandler({ handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`, actions: [ - "workspace.tab.new", + "workspace.agent.new", "workspace.tab.close-current", "workspace.tab.navigate-index", "workspace.tab.navigate-relative", diff --git a/packages/app/src/utils/format-shortcut.test.ts b/packages/app/src/utils/format-shortcut.test.ts index 29b5a32e4..03e394bcb 100644 --- a/packages/app/src/utils/format-shortcut.test.ts +++ b/packages/app/src/utils/format-shortcut.test.ts @@ -8,9 +8,9 @@ describe("formatShortcut", () => { expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E"); }); - it("spells out Shift in shortcut labels", () => { - expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab"); - expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P"); + it("uses the Shift symbol on macOS and spells it out elsewhere", () => { + expect(formatShortcut(["shift", "Tab"], "mac")).toBe("⇧Tab"); + expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("⇧⌘P"); expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab"); }); diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts index 4d79546e5..00fd7ca40 100644 --- a/packages/app/src/utils/format-shortcut.ts +++ b/packages/app/src/utils/format-shortcut.ts @@ -27,22 +27,18 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string { const order = ["ctrl", "alt", "shift", "mod", "meta"]; const symbols: Record = { mod: "⌘", + shift: "⇧", alt: "⌥", ctrl: "⌃", meta: "⌘", }; const modifierSet = new Set(normalized); - const mods = order - .filter((k) => modifierSet.has(k)) - .map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? ""))); + const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? ""); const main = normalized .filter((k) => !order.includes(k)) .map(normalizeKey) .join(""); - if (mods.includes("Shift")) { - return [...mods, main].filter(Boolean).join("+"); - } return `${mods.join("")}${main}`; }