feat(app): make the New tab launcher keyboard-first

Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:15:16 +02:00
parent 8655722296
commit 60bafad3ee
38 changed files with 1047 additions and 168 deletions
+70 -8
View File
@@ -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();
});
});
// ═══════════════════════════════════════════════════════════════════════════
+11 -1
View File
@@ -62,12 +62,22 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── 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<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
}
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /New agent/ })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
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. */
+24 -13
View File
@@ -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 (
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
<KeyboardActionDispatcherProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</KeyboardActionDispatcherProvider>
);
}
@@ -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,
@@ -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,
@@ -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" },
@@ -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",
}),
];
@@ -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({
@@ -207,7 +207,6 @@ export function MenuItem({
tooltip,
}: PropsWithChildren<MenuItemProps>): 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 = (
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityRole="menuitem"
tabIndex={-1}
dataSet={itemDataSet}
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
@@ -362,6 +372,8 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
borderRadius: theme.borderRadius.md,
outlineWidth: 0,
outlineColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface2,
@@ -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<HTMLElement>('[data-menu-item="true"]:not([data-menu-disabled="true"])')
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [contentSize, open, surfaceNativeID]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
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<View | null>;
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<HTMLElement>('[data-menu-surface="true"]');
if (!surface) return false;
const items = Array.from(
surface.querySelectorAll<HTMLElement>(
'[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;
@@ -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<T>(ref: Ref<T> | undefined, value: T | null): void {
}
export const MenuTrigger = forwardRef<View, MenuTriggerProps>(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<View, MenuTriggerProps>(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 (
<Pressable
{...props}
{...webExpandedState}
ref={handleTriggerRef}
collapsable={false}
disabled={disabled}
accessibilityState={resolvedAccessibilityState}
onPress={handlePress}
style={pressableStyle}
>
@@ -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 (
<MenuSurfaceContext.Provider value={surfaceValue}>
<MenuOverlay visible={menu.open} onClose={handleClose}>
<MenuOverlay visible={menu.open} onClose={handleClose} restoreFocusRef={menu.triggerRef}>
<>
<AnchoredSurface
open={menu.open}
@@ -226,6 +230,7 @@ function MenuPopoverSurface({
horizontalPadding={horizontalPadding}
scrollable={scrollable}
testID={testID}
keyboardFocusScope={keyboardFocusScope}
>
<MenuPage depth={0}>{children}</MenuPage>
</AnchoredSurface>
@@ -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"
>
<BottomSheetScrollView
dataSet={sheetDataSet}
contentContainerStyle={sheetScrollContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
@@ -1,10 +1,10 @@
import { useEffect, useRef } from "react";
import {
keyboardActionDispatcher,
type KeyboardActionDefinition,
type KeyboardActionId,
import type {
KeyboardActionDefinition,
KeyboardActionId,
} from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
interface UseKeyboardActionHandlerInput {
handlerId: string;
@@ -37,6 +37,7 @@ interface UseKeyboardActionHandlerInput {
* registration and would otherwise go stale).
*/
export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const inputRef = useRef(input);
inputRef.current = input;
@@ -58,5 +59,5 @@ export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
},
handle: (action) => inputRef.current.handle(action),
});
}, [input.handlerId, input.priority, actionsKey]);
}, [actionsKey, input.handlerId, input.priority, keyboardActionDispatcher]);
}
@@ -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,
+6
View File
@@ -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?",
+4
View File
@@ -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: "تبديل وضع الوكيل",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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: "エージェントモードを順に切り替え",
+4
View File
@@ -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: "에이전트 모드 전환",
+4
View File
@@ -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",
+4
View File
@@ -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: "Переключить режим агента",
+4
View File
@@ -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: "循环切换代理模式",
+6 -1
View File
@@ -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"
@@ -0,0 +1,28 @@
import { createContext, type ReactNode, useContext, useRef } from "react";
import { createKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
type KeyboardActionDispatcher = ReturnType<typeof createKeyboardActionDispatcher>;
const KeyboardActionDispatcherContext = createContext<KeyboardActionDispatcher | null>(null);
export function KeyboardActionDispatcherProvider({ children }: { children: ReactNode }) {
const dispatcherRef = useRef<KeyboardActionDispatcher | null>(null);
if (!dispatcherRef.current) {
dispatcherRef.current = createKeyboardActionDispatcher();
}
return (
<KeyboardActionDispatcherContext.Provider value={dispatcherRef.current}>
{children}
</KeyboardActionDispatcherContext.Provider>
);
}
export function useKeyboardActionDispatcher(): KeyboardActionDispatcher {
const dispatcher = useContext(KeyboardActionDispatcherContext);
if (!dispatcher) {
throw new Error("useKeyboardActionDispatcher must be used within its provider");
}
return dispatcher;
}
@@ -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();
});
});
@@ -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();
@@ -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"]]);
});
});
+122 -2
View File
@@ -171,7 +171,11 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
],
"tabs-panes": [
"workspace-tab-new",
"workspace-tab-target-agent",
"workspace-terminal-new",
"workspace-tab-target-browser",
"workspace-tab-target-changes",
"workspace-tab-target-files",
"workspace-tab-close-current",
"workspace-tab-jump-index",
"workspace-tab-prev",
@@ -187,6 +191,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
"workspace-pane-move-tab-up",
"workspace-pane-move-tab-down",
"workspace-pane-close",
"workspace-explorer-maximize",
],
layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"],
"agent-input": [
@@ -205,6 +210,10 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"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<string, string> = {
"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 ---
{
@@ -28,7 +28,11 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): 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({
+9 -1
View File
@@ -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<string, KeyboardActionDefinition> = {
"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<string, KeyboardActionDefinition> = {
"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" },
};
@@ -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("⌫");
});
+33 -10
View File
@@ -122,7 +122,10 @@ function getFocusableElements(scope: HTMLElement): HTMLElement[] {
}
function focusFirstElement(scope: HTMLElement): void {
const first = getFocusableElements(scope)[0];
const firstMenuItem = scope.querySelector<HTMLElement>(
'[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<unknown>;
}
/**
@@ -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<HTMLElement | null>(null);
const layerRef = useRef(layer);
const keyHandlerRef = useRef(onKeyDown);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const capturedRestoreFocusRef = useRef<HTMLElement | null>(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);
}, []);
@@ -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 (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient
id={gradientId}
x1={side === "left" ? "100%" : "0%"}
y1="0%"
x2={side === "left" ? "0%" : "100%"}
y2="0%"
>
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
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 (
<>
<Animated.View
pointerEvents="none"
testID="workspace-tabs-scroll-shade-left"
style={[styles.tabScrollShade, styles.tabScrollShadeLeft, leftStyle]}
>
<ThemedWorkspaceTabScrollShadeSvg side="left" uniProps={tabScrollShadeColorMapping} />
</Animated.View>
<Animated.View
pointerEvents="none"
testID="workspace-tabs-scroll-shade-right"
style={[styles.tabScrollShade, styles.tabScrollShadeRight, rightStyle]}
>
<ThemedWorkspaceTabScrollShadeSvg side="right" uniProps={tabScrollShadeColorMapping} />
</Animated.View>
</>
);
}
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 ? <Shortcut chord={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 ? <Shortcut chord={agentKeys} /> : undefined),
[agentKeys],
);
const terminalShortcut = useMemo(
() => (terminalKeys ? <Shortcut chord={terminalKeys} /> : undefined),
[terminalKeys],
);
const browserShortcut = useMemo(
() => (browserKeys ? <Shortcut chord={browserKeys} /> : undefined),
[browserKeys],
);
const changesShortcut = useMemo(
() => (changesKeys ? <Shortcut chord={changesKeys} /> : undefined),
[changesKeys],
);
const filesShortcut = useMemo(
() => (filesKeys ? <Shortcut chord={filesKeys} /> : undefined),
[filesKeys],
);
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<DropdownMenu>
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
@@ -263,14 +368,17 @@ function WorkspaceNewTabButton({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<DropdownMenuItem
testID="workspace-new-tab-menu-agent"
leading={AGENT_ICON}
trailing={shortcut}
trailing={agentShortcut}
onSelect={onCreateAgentTab}
>
{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({
<DropdownMenuItem
testID="workspace-new-tab-menu-browser"
leading={BROWSER_ICON}
trailing={browserShortcut}
onSelect={onCreateBrowser}
>
{t("workspace.tabs.actions.newBrowser")}
@@ -296,6 +406,7 @@ function WorkspaceNewTabButton({
<DropdownMenuItem
testID="workspace-new-tab-menu-changes"
leading={CHANGES_ICON}
trailing={changesShortcut}
onSelect={onOpenChanges}
>
{t("workspace.tabs.actions.changes")}
@@ -304,6 +415,7 @@ function WorkspaceNewTabButton({
<DropdownMenuItem
testID="workspace-new-tab-menu-files"
leading={FILES_ICON}
trailing={filesShortcut}
onSelect={onOpenFiles}
>
{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 (
<View style={[styles.inlineAddButton, styles.paneMaximizeButtonSlot]} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID={maximized ? "workspace-restore-pane" : "workspace-maximize-pane"}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
style={inlineAddActionButtonStyle}
>
{maximized ? (
<ThemedMinimize2 size={14} uniProps={extraMutedColorMapping} />
) : (
<ThemedMaximize2 size={14} uniProps={extraMutedColorMapping} />
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{label}</Text>
{maximizePaneKeys ? <Shortcut chord={maximizePaneKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
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 (
<View style={styles.exitFocusModeSlot} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-exit-focus-mode"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.exitFocusMode")}
style={inlineAddActionButtonStyle}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.exitFocusMode")}
</Text>
{focusModeKeys ? (
<Shortcut chord={focusModeKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
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<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const [newTabMenuOpen, setNewTabMenuOpen] = useState(false);
const tabScrollOffset = useSharedValue(0);
const tabScrollViewportWidth = useSharedValue(0);
const tabScrollContentWidth = useSharedValue(0);
const labelMeasurementsRef = useRef(new Map<string, WorkspaceTabLabelMeasurement>());
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(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({
/>
))}
</View>
{focusModeEnabled ? (
<View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-exit-focus-mode"
onPress={onExitFocusMode}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.exitFocusMode")}
style={inlineAddActionButtonStyle}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>
{t("workspace.tabs.actions.exitFocusMode")}
</Text>
{focusModeKeys ? (
<Shortcut chord={focusModeKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
) : null}
<ScrollView
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={tabsScrollStyle}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={displayedTabs}
keyExtractor={tabKeyExtractor}
useDragHandle
disabled={!externalDndContext && displayedTabs.length < 2}
onDragEnd={handleDragEnd}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={getTabDragData}
renderItem={renderTab}
/>
{!layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onLayout={handleInlineAddButtonLayout}
<WorkspaceExitFocusModeButton
visible={focusModeEnabled}
onPress={onExitFocusMode}
onLayout={handleExitFocusModeLayout}
/>
<View style={styles.tabsScrollContainer}>
<Animated.ScrollView
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={tabsScrollStyle}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
onLayout={handleTabScrollLayout}
onContentSizeChange={handleTabScrollContentSizeChange}
onScroll={handleTabScroll}
scrollEventThrottle={16}
>
<SortableInlineList
data={displayedTabs}
keyExtractor={tabKeyExtractor}
useDragHandle
disabled={!externalDndContext && displayedTabs.length < 2}
onDragEnd={handleDragEnd}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={getTabDragData}
renderItem={renderTab}
/>
) : null}
</ScrollView>
{!layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
open={newTabMenuOpen}
onOpenChange={setNewTabMenuOpen}
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onLayout={handleInlineAddButtonLayout}
/>
) : null}
</Animated.ScrollView>
<WorkspaceTabScrollShades
visible={layout.requiresHorizontalScrollFallback}
leftStyle={leftTabScrollShadeStyle}
rightStyle={rightTabScrollShadeStyle}
/>
</View>
{layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
open={newTabMenuOpen}
onOpenChange={setNewTabMenuOpen}
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
@@ -1359,6 +1671,12 @@ function ResolvedWorkspaceDesktopTabsRow({
onLayout={handleInlineAddButtonLayout}
/>
) : null}
<WorkspacePaneMaximizeButton
visible={showPaneMaximizeAction}
maximized={paneMaximized}
onPress={onTogglePaneMaximized}
onLayout={handlePaneMaximizeButtonLayout}
/>
</View>
);
@@ -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,
@@ -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",
@@ -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");
});
+2 -6
View File
@@ -27,22 +27,18 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
const order = ["ctrl", "alt", "shift", "mod", "meta"];
const symbols: Record<string, string> = {
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}`;
}