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, assertNewChatTileVisible,
assertNewTabMenuTriggerVisible, assertNewTabMenuTriggerVisible,
assertSingleNewTabButton, assertSingleNewTabButton,
pressNewTabShortcut, openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat, clickNewChat,
clickNewTerminal, clickNewTerminal,
countTabsOfKind, countTabsOfKind,
@@ -39,12 +40,32 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => { 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); 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 }) => { 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"); const countBefore = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page); await pressDirectNewTabShortcut(page, "a");
await expect await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1); .toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft"); const countAfterFirst = await countTabsOfKind(page, "draft");
// Blur the composer so the second shortcut isn't swallowed by the focused input await pressDirectNewTabShortcut(page, "a");
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
await pressNewTabShortcut(page);
await expect await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1); .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 }) => { test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
@@ -97,6 +143,22 @@ test.describe("Tab creation", () => {
await assertNewChatTileVisible(page); await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(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 ─────────────────────────────────────────────────────────── // ─── 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> { export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control"; const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`); 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 ─────────────────────────────────────────────────── // ─── Tab bar assertions ───────────────────────────────────────────────────
/** Assert the inline plus button is visible in the tab bar. */ /** 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 { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider"; import { MobilePanelsProvider } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/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 { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator"; import { polyfillNavigator } from "@/polyfills/navigator";
import { queryClient } from "@/data/query-client"; import { queryClient } from "@/data/query-client";
@@ -455,6 +458,7 @@ interface AppContainerProps {
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const daemons = useHosts(); const daemons = useHosts();
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
@@ -496,7 +500,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
scope: "sidebar", scope: "sidebar",
}), }),
}); });
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]); }, [
closeDesktopAgentList,
closeDesktopFileExplorer,
keyboardActionDispatcher,
openDesktopAgentList,
]);
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not // TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with // conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and // 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. // context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) {
return ( return (
<WindowChromeProvider> <KeyboardActionDispatcherProvider>
<KeyboardProvider> <WindowChromeProvider>
<KeyboardShiftProvider> <KeyboardProvider>
<ToastProvider> <KeyboardShiftProvider>
<PortalProvider> <ToastProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider> <PortalProvider>
</PortalProvider> <BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</ToastProvider> </PortalProvider>
</KeyboardShiftProvider> </ToastProvider>
</KeyboardProvider> </KeyboardShiftProvider>
</WindowChromeProvider> </KeyboardProvider>
</WindowChromeProvider>
</KeyboardActionDispatcherProvider>
); );
} }
@@ -44,7 +44,7 @@ import {
type CommandCenterScope, type CommandCenterScope,
} from "@/stores/keyboard-shortcuts-store"; } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-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 { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
@@ -220,6 +220,7 @@ interface CommandCenterState {
} }
function useCommandCenterState(): CommandCenterState { function useCommandCenterState(): CommandCenterState {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation(); const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope); const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
@@ -321,7 +322,7 @@ function useCommandCenterState(): CommandCenterState {
keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }), keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }),
}); });
return cancel; return cancel;
}, [open]); }, [keyboardActionDispatcher, open]);
return { return {
open, open,
@@ -16,7 +16,7 @@ import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; 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 { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -92,6 +92,7 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
} }
export function CommandCenterRootActions() { export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation(); const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const shortcutsAvailable = useKeyboardShortcutsAvailable(); const shortcutsAvailable = useKeyboardShortcutsAvailable();
@@ -266,6 +267,7 @@ export function CommandCenterRootActions() {
}, [ }, [
groupMode, groupMode,
homeRoute, homeRoute,
keyboardActionDispatcher,
openAddProject, openAddProject,
overrides, overrides,
schedulesRoute, schedulesRoute,
@@ -114,7 +114,7 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run(); for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([ expect(fixture.dispatched).toEqual([
{ id: "workspace.tab.new", scope: "workspace" }, { id: "workspace.agent.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" }, { id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" }, { id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" }, { id: "workspace.pane.split.right", scope: "workspace" },
@@ -108,7 +108,7 @@ export function buildWorkspaceCommandCenterContributions(
keywords: ["tab", "new", "agent", "chat"], keywords: ["tab", "new", "agent", "chat"],
icon: source.icons.newAgent, icon: source.icons.newAgent,
shortcutKeys: source.shortcuts.newAgent, shortcutKeys: source.shortcuts.newAgent,
action: { id: "workspace.tab.new", scope: "workspace" }, action: { id: "workspace.agent.new", scope: "workspace" },
visibility: "always", visibility: "always",
}), }),
]; ];
@@ -10,7 +10,7 @@ import {
resolveShortcutKeysForAction, resolveShortcutKeysForAction,
type ShortcutOverrides, type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts"; } 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 { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -55,6 +55,7 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
} }
export function useWorkspaceCommandCenterActions(): void { export function useWorkspaceCommandCenterActions(): void {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation(); const { t } = useTranslation();
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
@@ -96,7 +97,7 @@ export function useWorkspaceCommandCenterActions(): void {
}, },
runGitAction, runGitAction,
}), }),
[gitActions, isCompact, overrides, runGitAction, t], [gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
); );
useCommandCenterActions({ useCommandCenterActions({
@@ -207,7 +207,6 @@ export function MenuItem({
tooltip, tooltip,
}: PropsWithChildren<MenuItemProps>): ReactElement { }: PropsWithChildren<MenuItemProps>): ReactElement {
const { selectItem } = useMenuContext("MenuItem"); const { selectItem } = useMenuContext("MenuItem");
const isPending = status === "pending" || loading; const isPending = status === "pending" || loading;
const isSuccess = status === "success"; const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess; const isDisabled = disabled || isPending || isSuccess;
@@ -230,12 +229,17 @@ export function MenuItem({
}, [isDisabled, selectItem, onSelect, closeOnSelect]); }, [isDisabled, selectItem, onSelect, closeOnSelect]);
const itemPressableStyle = useCallback( const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ ({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) => [
styles.item, styles.item,
active ? styles.itemActive : null, active ? styles.itemActive : null,
isDisabled ? styles.itemDisabled : null, isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null, muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null,
focused && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null, pressed && !isDisabled ? styles.itemPressed : null,
], ],
[active, isDisabled, muted], [active, isDisabled, muted],
@@ -250,11 +254,17 @@ export function MenuItem({
], ],
[destructive, isSuccess, muted, isDisabled], [destructive, isSuccess, muted, isDisabled],
); );
const itemDataSet = useMemo(
() => ({ menuItem: "true", menuDisabled: isDisabled ? "true" : "false" }),
[isDisabled],
);
const content = ( const content = (
<Pressable <Pressable
testID={testID} testID={testID}
accessibilityRole="button" accessibilityRole="menuitem"
tabIndex={-1}
dataSet={itemDataSet}
disabled={isDisabled} disabled={isDisabled}
onPress={handleItemPress} onPress={handleItemPress}
style={itemPressableStyle} style={itemPressableStyle}
@@ -362,6 +372,8 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: "transparent", borderColor: "transparent",
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
outlineWidth: 0,
outlineColor: "transparent",
}, },
itemHovered: { itemHovered: {
backgroundColor: theme.colors.surface2, 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 { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -15,6 +23,7 @@ import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { KeyboardFocusScope } from "@/keyboard/actions";
import { import {
getOverlayRoot, getOverlayRoot,
OverlayLayerProvider, OverlayLayerProvider,
@@ -230,6 +239,7 @@ export interface AnchoredSurfaceProps {
onPointerEnter?: () => void; onPointerEnter?: () => void;
onPointerLeave?: () => void; onPointerLeave?: () => void;
testID?: string; testID?: string;
keyboardFocusScope?: KeyboardFocusScope;
children: ReactElement; children: ReactElement;
} }
@@ -258,6 +268,7 @@ export function AnchoredSurface({
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
testID, testID,
keyboardFocusScope,
children, children,
}: AnchoredSurfaceProps): ReactElement | null { }: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -281,6 +292,17 @@ export function AnchoredSurface({
revision, 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 frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window"); const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth const resolvedWidthStyle: ViewStyle = fullWidth
@@ -315,6 +337,13 @@ export function AnchoredSurface({
() => [visibleContentSize ? { height: visibleContentSize.height } : null], () => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize], [visibleContentSize],
); );
const surfaceDataSet = useMemo(
() => ({
menuSurface: "true",
...(keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : null),
}),
[keyboardFocusScope],
);
if (!open) return null; if (!open) return null;
@@ -346,6 +375,7 @@ export function AnchoredSurface({
tabIndex={-1} tabIndex={-1}
nativeID={surfaceNativeID} nativeID={surfaceNativeID}
testID={testID} testID={testID}
dataSet={surfaceDataSet}
style={styles.content} style={styles.content}
frameStyle={frameStyle} frameStyle={frameStyle}
entering={contentEntering} entering={contentEntering}
@@ -385,21 +415,56 @@ export function AnchoredSurface({
export function MenuOverlay({ export function MenuOverlay({
visible, visible,
onClose, onClose,
restoreFocusRef,
children, children,
}: { }: {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
restoreFocusRef?: RefObject<View | null>;
children: ReactElement | null; children: ReactElement | null;
}): ReactElement | null { }): ReactElement | null {
const floatingLayer = useOverlayLayer("floating"); const floatingLayer = useOverlayLayer("floating");
const handleWebOverlayKeyDown = useCallback( const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (event.key !== "Escape") return false; if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onClose(); onClose();
return true; 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], [onClose],
); );
@@ -407,6 +472,7 @@ export function MenuOverlay({
active: isWeb && visible, active: isWeb && visible,
layer: floatingLayer, layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown, onKeyDown: handleWebOverlayKeyDown,
restoreFocusRef,
}); });
if (!visible) return null; if (!visible) return null;
@@ -1,6 +1,7 @@
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
useMemo,
type PropsWithChildren, type PropsWithChildren,
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
@@ -20,6 +21,7 @@ import {
useMenuState, useMenuState,
type MenuCompactMode, type MenuCompactMode,
} from "./menu-context"; } from "./menu-context";
import { isWeb } from "@/constants/platform";
/** /**
* Owns one menu's state. Wrap a trigger and a `MenuSurface` in it. * 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( export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigger(
{ children, disabled, style, ...props }, { children, disabled, style, accessibilityState, ...props },
forwardedRef, forwardedRef,
): ReactElement { ): ReactElement {
const ctx = useMenuContext("MenuTrigger"); const ctx = useMenuContext("MenuTrigger");
@@ -102,13 +104,23 @@ export const MenuTrigger = forwardRef<View, MenuTriggerProps>(function MenuTrigg
}, },
[children, ctx.open], [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 ( return (
<Pressable <Pressable
{...props} {...props}
{...webExpandedState}
ref={handleTriggerRef} ref={handleTriggerRef}
collapsable={false} collapsable={false}
disabled={disabled} disabled={disabled}
accessibilityState={resolvedAccessibilityState}
onPress={handlePress} onPress={handlePress}
style={pressableStyle} style={pressableStyle}
> >
@@ -27,6 +27,7 @@ import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
import type { Alignment, Placement } from "./menu-anchor"; import type { Alignment, Placement } from "./menu-anchor";
import type { KeyboardFocusScope } from "@/keyboard/actions";
const ThemedChevronLeft = withUnistyles(ChevronLeft); const ThemedChevronLeft = withUnistyles(ChevronLeft);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -105,6 +106,8 @@ export interface MenuSurfaceProps {
horizontalPadding?: number; horizontalPadding?: number;
scrollable?: boolean; scrollable?: boolean;
testID?: string; testID?: string;
/** Limits ordinary app shortcuts to the time this menu owns keyboard focus. */
keyboardFocusScope?: KeyboardFocusScope;
} }
/** /**
@@ -188,6 +191,7 @@ function MenuPopoverSurface({
horizontalPadding = 16, horizontalPadding = 16,
scrollable = false, scrollable = false,
testID, testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null { }: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface"); const menu = useMenuContext("MenuSurface");
const { value: surfaceValue, getAnchor } = useSubAnchors(); const { value: surfaceValue, getAnchor } = useSubAnchors();
@@ -208,7 +212,7 @@ function MenuPopoverSurface({
return ( return (
<MenuSurfaceContext.Provider value={surfaceValue}> <MenuSurfaceContext.Provider value={surfaceValue}>
<MenuOverlay visible={menu.open} onClose={handleClose}> <MenuOverlay visible={menu.open} onClose={handleClose} restoreFocusRef={menu.triggerRef}>
<> <>
<AnchoredSurface <AnchoredSurface
open={menu.open} open={menu.open}
@@ -226,6 +230,7 @@ function MenuPopoverSurface({
horizontalPadding={horizontalPadding} horizontalPadding={horizontalPadding}
scrollable={scrollable} scrollable={scrollable}
testID={testID} testID={testID}
keyboardFocusScope={keyboardFocusScope}
> >
<MenuPage depth={0}>{children}</MenuPage> <MenuPage depth={0}>{children}</MenuPage>
</AnchoredSurface> </AnchoredSurface>
@@ -304,6 +309,7 @@ function MenuSheetSurface({
pages = [], pages = [],
sheetTitle, sheetTitle,
testID, testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null { }: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface"); const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors(); const { value: surfaceValue } = useSubAnchors();
@@ -318,6 +324,10 @@ function MenuSheetSurface({
}), }),
[safeAreaInsets.bottom], [safeAreaInsets.bottom],
); );
const sheetDataSet = useMemo(
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
[keyboardFocusScope],
);
const handleClose = useCallback(() => menu.setOpen(false), [menu]); const handleClose = useCallback(() => menu.setOpen(false), [menu]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
@@ -369,6 +379,7 @@ function MenuSheetSurface({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<BottomSheetScrollView <BottomSheetScrollView
dataSet={sheetDataSet}
contentContainerStyle={sheetScrollContentStyle} contentContainerStyle={sheetScrollContentStyle}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -1,10 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import type {
keyboardActionDispatcher, KeyboardActionDefinition,
type KeyboardActionDefinition, KeyboardActionId,
type KeyboardActionId,
} from "@/keyboard/keyboard-action-dispatcher"; } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
interface UseKeyboardActionHandlerInput { interface UseKeyboardActionHandlerInput {
handlerId: string; handlerId: string;
@@ -37,6 +37,7 @@ interface UseKeyboardActionHandlerInput {
* registration and would otherwise go stale). * registration and would otherwise go stale).
*/ */
export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) { export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const inputRef = useRef(input); const inputRef = useRef(input);
inputRef.current = input; inputRef.current = input;
@@ -58,5 +59,5 @@ export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
}, },
handle: (action) => inputRef.current.handle(action), 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 { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-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 { import {
type ChordState, type ChordState,
type KeyboardShortcutInput, type KeyboardShortcutInput,
@@ -37,6 +37,7 @@ import {
navigateToLastWorkspace, navigateToLastWorkspace,
useActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { dispatchTopWebOverlayKeyDown } from "@/lib/overlay-root";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -55,6 +56,7 @@ export function useKeyboardShortcuts({
exitFocusMode: () => void; exitFocusMode: () => void;
cycleTheme?: () => void; cycleTheme?: () => void;
}) { }) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
@@ -269,7 +271,6 @@ export function useKeyboardShortcuts({
}, },
bindings, bindings,
}); });
chordStateRef.current = result.nextChordState; chordStateRef.current = result.nextChordState;
if ( if (
shouldPublishBrowserShortcutPolicy({ shouldPublishBrowserShortcutPolicy({
@@ -313,6 +314,10 @@ export function useKeyboardShortcuts({
return; return;
} }
if (dispatchTopWebOverlayKeyDown(event)) {
return;
}
// During IME composition, Enter confirms the candidate selection and must // During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send. // not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) { if (isImeComposingKeyboardEvent(event)) {
@@ -405,6 +410,7 @@ export function useKeyboardShortcuts({
isMac, isMac,
isMobile, isMobile,
isWorkspaceFocusModeEnabled, isWorkspaceFocusModeEnabled,
keyboardActionDispatcher,
openProjectPickerAction, openProjectPickerAction,
pathname, pathname,
publishBrowserShortcutPolicy, publishBrowserShortcutPolicy,
+6
View File
@@ -497,6 +497,12 @@ describe("translation resources", () => {
expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview."); expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview.");
expect(en.workspace.tabs.explorer.changes).toBe("Changes"); expect(en.workspace.tabs.explorer.changes).toBe("Changes");
expect(en.workspace.tabs.explorer.files).toBe("Files"); 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.uncommittedTitle).toBe("Uncommitted changes");
expect(en.branchSwitcher.uncommittedMessage).toBe( expect(en.branchSwitcher.uncommittedMessage).toBe(
"You have uncommitted changes. Stash them before switching branches?", "You have uncommitted changes. Stash them before switching branches?",
+4
View File
@@ -592,6 +592,8 @@ export const ar: TranslationResources = {
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
preparingTerminalTooltip: "جارٍ تحضير المحطة...", preparingTerminalTooltip: "جارٍ تحضير المحطة...",
newBrowser: "متصفح جديد", newBrowser: "متصفح جديد",
maximizePane: "تكبير الجزء",
restorePane: "استعادة الجزء",
exitFocusMode: "إنهاء وضع التركيز", exitFocusMode: "إنهاء وضع التركيز",
splitRight: "تقسيم الجزء الأيمن", splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل", splitDown: "تقسيم الجزء لأسفل",
@@ -1353,6 +1355,7 @@ export const ar: TranslationResources = {
openPath: "فتح المسار", openPath: "فتح المسار",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "تبديل فرع مساحة العمل",
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
placeholder: "تبديل الفرع...", placeholder: "تبديل الفرع...",
searchPlaceholder: "تصفية الفروع...", searchPlaceholder: "تصفية الفروع...",
@@ -2053,6 +2056,7 @@ export const ar: TranslationResources = {
toggleBothSidebars: "تبديل كلا الشريطين الجانبيين", toggleBothSidebars: "تبديل كلا الشريطين الجانبيين",
toggleSettings: "تبديل الإعدادات", toggleSettings: "تبديل الإعدادات",
toggleFocusMode: "تبديل وضع التركيز", toggleFocusMode: "تبديل وضع التركيز",
toggleExplorerPaneMaximization: "تبديل تكبير جزء المستكشف",
cycleTheme: "موضوع الدورة", cycleTheme: "موضوع الدورة",
focusMessageInput: "التركيز على إدخال الرسالة", focusMessageInput: "التركيز على إدخال الرسالة",
cycleAgentMode: "تبديل وضع الوكيل", cycleAgentMode: "تبديل وضع الوكيل",
+4
View File
@@ -590,6 +590,8 @@ export const en = {
preparingTerminal: "Preparing terminal tab", preparingTerminal: "Preparing terminal tab",
preparingTerminalTooltip: "Preparing terminal...", preparingTerminalTooltip: "Preparing terminal...",
newBrowser: "New browser", newBrowser: "New browser",
maximizePane: "Maximize pane",
restorePane: "Restore pane",
exitFocusMode: "Exit focus mode", exitFocusMode: "Exit focus mode",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", splitDown: "Split pane down",
@@ -1362,6 +1364,7 @@ export const en = {
openPath: "Open path", openPath: "Open path",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "Switch workspace branch",
currentBranch: "Current branch: {{branchName}}. Press to switch branch.", currentBranch: "Current branch: {{branchName}}. Press to switch branch.",
placeholder: "Switch branch...", placeholder: "Switch branch...",
searchPlaceholder: "Filter branches...", searchPlaceholder: "Filter branches...",
@@ -2117,6 +2120,7 @@ export const en = {
toggleBothSidebars: "Toggle both sidebars", toggleBothSidebars: "Toggle both sidebars",
toggleSettings: "Toggle settings", toggleSettings: "Toggle settings",
toggleFocusMode: "Toggle focus mode", toggleFocusMode: "Toggle focus mode",
toggleExplorerPaneMaximization: "Toggle Explorer pane maximization",
cycleTheme: "Cycle theme", cycleTheme: "Cycle theme",
focusMessageInput: "Focus message input", focusMessageInput: "Focus message input",
cycleAgentMode: "Cycle agent mode", cycleAgentMode: "Cycle agent mode",
+4
View File
@@ -597,6 +597,8 @@ export const es: TranslationResources = {
preparingTerminal: "Preparando la pestaña del terminal", preparingTerminal: "Preparando la pestaña del terminal",
preparingTerminalTooltip: "Preparando terminal...", preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Nuevo navegador", newBrowser: "Nuevo navegador",
maximizePane: "Maximizar panel",
restorePane: "Restaurar panel",
exitFocusMode: "Salir del modo de concentración", exitFocusMode: "Salir del modo de concentración",
splitRight: "Panel dividido a la derecha", splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo", splitDown: "Dividir panel hacia abajo",
@@ -1393,6 +1395,7 @@ export const es: TranslationResources = {
openPath: "Abrir ruta", openPath: "Abrir ruta",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "Cambiar rama del espacio de trabajo",
currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.",
placeholder: "Cambiar de rama...", placeholder: "Cambiar de rama...",
searchPlaceholder: "Filtrar ramas...", searchPlaceholder: "Filtrar ramas...",
@@ -2101,6 +2104,7 @@ export const es: TranslationResources = {
toggleBothSidebars: "Alternar ambas barras laterales", toggleBothSidebars: "Alternar ambas barras laterales",
toggleSettings: "Alternar configuración", toggleSettings: "Alternar configuración",
toggleFocusMode: "Alternar modo de enfoque", toggleFocusMode: "Alternar modo de enfoque",
toggleExplorerPaneMaximization: "Alternar maximización del panel Explorador",
cycleTheme: "Tema del ciclo", cycleTheme: "Tema del ciclo",
focusMessageInput: "Entrada de mensaje de enfoque", focusMessageInput: "Entrada de mensaje de enfoque",
cycleAgentMode: "Alternar modo del agente", cycleAgentMode: "Alternar modo del agente",
+4
View File
@@ -597,6 +597,8 @@ export const fr: TranslationResources = {
preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminal: "Préparation de l'onglet du terminal",
preparingTerminalTooltip: "Préparation du terminal...", preparingTerminalTooltip: "Préparation du terminal...",
newBrowser: "Nouveau navigateur", newBrowser: "Nouveau navigateur",
maximizePane: "Agrandir le volet",
restorePane: "Restaurer le volet",
exitFocusMode: "Quitter le mode concentration", exitFocusMode: "Quitter le mode concentration",
splitRight: "Volet divisé à droite", splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas", splitDown: "Diviser le volet vers le bas",
@@ -1396,6 +1398,7 @@ export const fr: TranslationResources = {
openPath: "Ouvrir le chemin", openPath: "Ouvrir le chemin",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "Changer la branche de l’espace de travail",
currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.",
placeholder: "Changer de branche...", placeholder: "Changer de branche...",
searchPlaceholder: "Filtrer les branches...", searchPlaceholder: "Filtrer les branches...",
@@ -2105,6 +2108,7 @@ export const fr: TranslationResources = {
toggleBothSidebars: "Basculer les deux barres latérales", toggleBothSidebars: "Basculer les deux barres latérales",
toggleSettings: "Basculer les paramètres", toggleSettings: "Basculer les paramètres",
toggleFocusMode: "Basculer le mode de mise au point", toggleFocusMode: "Basculer le mode de mise au point",
toggleExplorerPaneMaximization: "Basculer l’agrandissement du volet Explorateur",
cycleTheme: "Thème du cycle", cycleTheme: "Thème du cycle",
focusMessageInput: "Saisie du message de focus", focusMessageInput: "Saisie du message de focus",
cycleAgentMode: "Parcourir les modes de l'agent", cycleAgentMode: "Parcourir les modes de l'agent",
+4
View File
@@ -597,6 +597,8 @@ export const ja: TranslationResources = {
preparingTerminal: "ターミナルタブを準備中", preparingTerminal: "ターミナルタブを準備中",
preparingTerminalTooltip: "ターミナルを準備中...", preparingTerminalTooltip: "ターミナルを準備中...",
newBrowser: "新しいブラウザ", newBrowser: "新しいブラウザ",
maximizePane: "ペインを最大化",
restorePane: "ペインを元に戻す",
exitFocusMode: "フォーカスモードを終了", exitFocusMode: "フォーカスモードを終了",
splitRight: "右にペインを分割", splitRight: "右にペインを分割",
splitDown: "下にペインを分割", splitDown: "下にペインを分割",
@@ -1366,6 +1368,7 @@ export const ja: TranslationResources = {
openPath: "パスを開く", openPath: "パスを開く",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "ワークスペースのブランチを切り替え",
currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。", currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。",
placeholder: "ブランチを切り替え...", placeholder: "ブランチを切り替え...",
searchPlaceholder: "ブランチをフィルタ...", searchPlaceholder: "ブランチをフィルタ...",
@@ -2070,6 +2073,7 @@ export const ja: TranslationResources = {
toggleBothSidebars: "両方のサイドバーを切り替え", toggleBothSidebars: "両方のサイドバーを切り替え",
toggleSettings: "設定を切り替え", toggleSettings: "設定を切り替え",
toggleFocusMode: "フォーカスモードを切り替え", toggleFocusMode: "フォーカスモードを切り替え",
toggleExplorerPaneMaximization: "エクスプローラーペインの最大化を切り替え",
cycleTheme: "テーマを順に切り替え", cycleTheme: "テーマを順に切り替え",
focusMessageInput: "メッセージ入力にフォーカス", focusMessageInput: "メッセージ入力にフォーカス",
cycleAgentMode: "エージェントモードを順に切り替え", cycleAgentMode: "エージェントモードを順に切り替え",
+4
View File
@@ -594,6 +594,8 @@ export const ko: TranslationResources = {
preparingTerminal: "터미널 탭 준비 중", preparingTerminal: "터미널 탭 준비 중",
preparingTerminalTooltip: "터미널 준비 중...", preparingTerminalTooltip: "터미널 준비 중...",
newBrowser: "새 브라우저", newBrowser: "새 브라우저",
maximizePane: "창 최대화",
restorePane: "창 복원",
exitFocusMode: "집중 모드 종료", exitFocusMode: "집중 모드 종료",
splitRight: "창을 오른쪽으로 분할", splitRight: "창을 오른쪽으로 분할",
splitDown: "창을 아래로 분할", splitDown: "창을 아래로 분할",
@@ -1362,6 +1364,7 @@ export const ko: TranslationResources = {
openPath: "경로 열기", openPath: "경로 열기",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "워크스페이스 브랜치 전환",
currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.", currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.",
placeholder: "브랜치 전환...", placeholder: "브랜치 전환...",
searchPlaceholder: "브랜치 필터...", searchPlaceholder: "브랜치 필터...",
@@ -2064,6 +2067,7 @@ export const ko: TranslationResources = {
toggleBothSidebars: "양쪽 사이드바 토글", toggleBothSidebars: "양쪽 사이드바 토글",
toggleSettings: "설정 토글", toggleSettings: "설정 토글",
toggleFocusMode: "집중 모드 토글", toggleFocusMode: "집중 모드 토글",
toggleExplorerPaneMaximization: "탐색기 창 최대화 전환",
cycleTheme: "테마 순환", cycleTheme: "테마 순환",
focusMessageInput: "메시지 입력란에 포커스", focusMessageInput: "메시지 입력란에 포커스",
cycleAgentMode: "에이전트 모드 전환", cycleAgentMode: "에이전트 모드 전환",
+4
View File
@@ -596,6 +596,8 @@ export const ptBR: TranslationResources = {
preparingTerminal: "Preparando aba de terminal", preparingTerminal: "Preparando aba de terminal",
preparingTerminalTooltip: "Preparando terminal...", preparingTerminalTooltip: "Preparando terminal...",
newBrowser: "Novo navegador", newBrowser: "Novo navegador",
maximizePane: "Maximizar painel",
restorePane: "Restaurar painel",
exitFocusMode: "Sair do modo de foco", exitFocusMode: "Sair do modo de foco",
splitRight: "Dividir painel à direita", splitRight: "Dividir painel à direita",
splitDown: "Dividir painel abaixo", splitDown: "Dividir painel abaixo",
@@ -1379,6 +1381,7 @@ export const ptBR: TranslationResources = {
openPath: "Abrir caminho", openPath: "Abrir caminho",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "Trocar branch do workspace",
currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.", currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.",
placeholder: "Trocar branch...", placeholder: "Trocar branch...",
searchPlaceholder: "Filtrar branches...", searchPlaceholder: "Filtrar branches...",
@@ -2084,6 +2087,7 @@ export const ptBR: TranslationResources = {
toggleBothSidebars: "Alternar ambas as barras laterais", toggleBothSidebars: "Alternar ambas as barras laterais",
toggleSettings: "Alternar Configurações", toggleSettings: "Alternar Configurações",
toggleFocusMode: "Alternar modo de foco", toggleFocusMode: "Alternar modo de foco",
toggleExplorerPaneMaximization: "Alternar maximização do painel Explorador",
cycleTheme: "Alternar tema", cycleTheme: "Alternar tema",
focusMessageInput: "Focar entrada de mensagem", focusMessageInput: "Focar entrada de mensagem",
cycleAgentMode: "Alternar modo do agente", cycleAgentMode: "Alternar modo do agente",
+4
View File
@@ -596,6 +596,8 @@ export const ru: TranslationResources = {
preparingTerminal: "Подготовка вкладки терминала", preparingTerminal: "Подготовка вкладки терминала",
preparingTerminalTooltip: "Подготовка терминала...", preparingTerminalTooltip: "Подготовка терминала...",
newBrowser: "Новый браузер", newBrowser: "Новый браузер",
maximizePane: "Развернуть панель",
restorePane: "Восстановить панель",
exitFocusMode: "Выйти из режима фокусировки", exitFocusMode: "Выйти из режима фокусировки",
splitRight: "Разделить панель справа", splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз", splitDown: "Разделить панель вниз",
@@ -1382,6 +1384,7 @@ export const ru: TranslationResources = {
openPath: "Открыть путь", openPath: "Открыть путь",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "Переключить ветку рабочего пространства",
currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.", currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.",
placeholder: "Сменить ветку...", placeholder: "Сменить ветку...",
searchPlaceholder: "Фильтровать ветки...", searchPlaceholder: "Фильтровать ветки...",
@@ -2092,6 +2095,7 @@ export const ru: TranslationResources = {
toggleBothSidebars: "Переключить обе боковые панели", toggleBothSidebars: "Переключить обе боковые панели",
toggleSettings: "Переключить настройки", toggleSettings: "Переключить настройки",
toggleFocusMode: "Переключить режим фокусировки", toggleFocusMode: "Переключить режим фокусировки",
toggleExplorerPaneMaximization: "Переключить разворачивание панели Проводника",
cycleTheme: "Циклическая тема", cycleTheme: "Циклическая тема",
focusMessageInput: "Фокус ввода сообщения", focusMessageInput: "Фокус ввода сообщения",
cycleAgentMode: "Переключить режим агента", cycleAgentMode: "Переключить режим агента",
+4
View File
@@ -592,6 +592,8 @@ export const zhCN: TranslationResources = {
preparingTerminal: "正在准备 Terminal 标签", preparingTerminal: "正在准备 Terminal 标签",
preparingTerminalTooltip: "正在准备 Terminal...", preparingTerminalTooltip: "正在准备 Terminal...",
newBrowser: "新建浏览器", newBrowser: "新建浏览器",
maximizePane: "最大化窗格",
restorePane: "还原窗格",
exitFocusMode: "退出专注模式", exitFocusMode: "退出专注模式",
splitRight: "向右拆分窗格", splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格", splitDown: "向下拆分窗格",
@@ -1336,6 +1338,7 @@ export const zhCN: TranslationResources = {
openPath: "打开路径", openPath: "打开路径",
}, },
branchSwitcher: { branchSwitcher: {
triggerTooltip: "切换工作区分支",
currentBranch: "当前分支:{{branchName}}。按下以切换分支。", currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
placeholder: "切换分支...", placeholder: "切换分支...",
searchPlaceholder: "筛选分支...", searchPlaceholder: "筛选分支...",
@@ -2029,6 +2032,7 @@ export const zhCN: TranslationResources = {
toggleBothSidebars: "切换两侧边栏", toggleBothSidebars: "切换两侧边栏",
toggleSettings: "切换设置", toggleSettings: "切换设置",
toggleFocusMode: "切换专注模式", toggleFocusMode: "切换专注模式",
toggleExplorerPaneMaximization: "切换资源管理器窗格最大化",
cycleTheme: "循环切换主题", cycleTheme: "循环切换主题",
focusMessageInput: "聚焦消息输入框", focusMessageInput: "聚焦消息输入框",
cycleAgentMode: "循环切换代理模式", cycleAgentMode: "循环切换代理模式",
+6 -1
View File
@@ -19,7 +19,11 @@ export type MessageInputKeyboardActionKind =
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt" | "agent.interrupt"
| "agent.new" | "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.close.current"
| "workspace.tab.navigate.index" | "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative" | "workspace.tab.navigate.relative"
@@ -34,6 +38,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.up" | "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down" | "workspace.pane.move-tab.down"
| "workspace.pane.close" | "workspace.pane.close"
| "workspace.explorer.maximize.toggle"
| "workspace.navigate.index" | "workspace.navigate.index"
| "workspace.navigate.relative" | "workspace.navigate.relative"
| "sidebar.toggle.left" | "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(handled).toBe(false);
expect(handle).not.toHaveBeenCalled(); 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-toggle"
| "message-input.voice-mute-toggle" | "message-input.voice-mute-toggle"
| "message-input.mode-cycle" | "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.close-current"
| "workspace.tab.navigate-index" | "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative" | "workspace.tab.navigate-relative"
@@ -25,6 +30,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.up" | "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down" | "workspace.pane.move-tab.down"
| "workspace.pane.close" | "workspace.pane.close"
| "workspace.explorer.maximize.toggle"
| "workspace.focus.toggle" | "workspace.focus.toggle"
| "workspace.terminal.new" | "workspace.terminal.new"
| "workspace.browser.new" | "workspace.browser.new"
@@ -45,7 +51,12 @@ export type KeyboardActionDefinition =
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "message-input.mode-cycle"; 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.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } | { 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.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.explorer.maximize.toggle"; scope: KeyboardActionScope }
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope } | { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "workspace.browser.new"; scope: KeyboardActionScope } | { id: "workspace.browser.new"; scope: KeyboardActionScope }
@@ -89,14 +101,15 @@ export function createKeyboardActionDispatcher() {
return { return {
registerHandler(handler: KeyboardActionHandler) { registerHandler(handler: KeyboardActionHandler) {
handlers.set(handler.handlerId, { const entry: KeyboardActionRegistryEntry = {
...handler, ...handler,
registeredAt: nextRegistrationOrder++, registeredAt: nextRegistrationOrder++,
}); };
handlers.set(handler.handlerId, entry);
return () => { return () => {
const current = handlers.get(handler.handlerId); const current = handlers.get(handler.handlerId);
if (!current) { if (current !== entry) {
return; return;
} }
handlers.delete(handler.handlerId); 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", name: "matches Mod+T to open new tab",
event: { key: "t", code: "KeyT", metaKey: true }, event: { key: "t", code: "KeyT", metaKey: true },
context: { isMac: true }, context: { isMac: true },
action: "workspace.tab.new", action: "workspace.tab.menu.open",
}, },
{ {
name: "matches Alt+Shift+W to close current tab on web", name: "matches Alt+Shift+W to close current tab on web",
@@ -270,6 +270,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true }, context: { isMac: true },
action: "workspace.pane.split.down", 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", name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS",
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true }, event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
@@ -589,7 +601,7 @@ describe("keyboard-shortcuts", () => {
expectShortcutResolution({ expectShortcutResolution({
event: { key: "t", code: "KeyT", ctrlKey: true }, event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isDesktop: true, focusScope: "browser" }, 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-tab-close-current": ["alt", "shift", "W"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
"workspace-explorer-maximize": ["mod", "shift", "M"],
"cycle-agent-mode": ["shift", "Tab"], "cycle-agent-mode": ["shift", "Tab"],
}, },
}, },
@@ -661,6 +674,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-tab-close-current": ["mod", "W"], "workspace-tab-close-current": ["mod", "W"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
"workspace-explorer-maximize": ["mod", "shift", "M"],
}, },
}, },
{ {
@@ -669,6 +683,7 @@ describe("keyboard-shortcut help sections", () => {
expectedKeys: { expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"], "workspace-tab-close-current": ["ctrl", "W"],
"workspace-explorer-maximize": ["ctrl", "shift", "M"],
}, },
}, },
{ {
@@ -993,7 +1008,7 @@ describe("unassigned shortcuts", () => {
bindings, 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", () => { it("treats a stored empty combo as unassigned too", () => {
@@ -1017,7 +1032,7 @@ describe("unassigned shortcuts", () => {
context: desktopNonMac, context: desktopNonMac,
bindings, bindings,
}).match?.action, }).match?.action,
).toBe("workspace.tab.new"); ).toBe("workspace.tab.menu.open");
expect( expect(
resolveShortcut({ resolveShortcut({
event: { key: "t", code: "KeyT", ctrlKey: true }, event: { key: "t", code: "KeyT", ctrlKey: true },
@@ -1038,7 +1053,7 @@ describe("unassigned shortcuts", () => {
bindings, 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": [ "tabs-panes": [
"workspace-tab-new", "workspace-tab-new",
"workspace-tab-target-agent",
"workspace-terminal-new", "workspace-terminal-new",
"workspace-tab-target-browser",
"workspace-tab-target-changes",
"workspace-tab-target-files",
"workspace-tab-close-current", "workspace-tab-close-current",
"workspace-tab-jump-index", "workspace-tab-jump-index",
"workspace-tab-prev", "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-up",
"workspace-pane-move-tab-down", "workspace-pane-move-tab-down",
"workspace-pane-close", "workspace-pane-close",
"workspace-explorer-maximize",
], ],
layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"], layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"],
"agent-input": [ "agent-input": [
@@ -205,6 +210,10 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"switch-project": "settings.shortcuts.help.switchProject", "switch-project": "settings.shortcuts.help.switchProject",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace", "archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"workspace-tab-new": "settings.shortcuts.help.newTab", "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-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace", "workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
"workspace-tab-jump-index": "settings.shortcuts.help.jumpToTab", "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-up": "settings.shortcuts.help.moveTabUp",
"workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown", "workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown",
"workspace-pane-close": "settings.shortcuts.help.closePane", "workspace-pane-close": "settings.shortcuts.help.closePane",
"workspace-explorer-maximize": "settings.shortcuts.help.toggleExplorerPaneMaximization",
"workspace-terminal-new": "settings.shortcuts.help.newTerminal", "workspace-terminal-new": "settings.shortcuts.help.newTerminal",
"search-files": "settings.shortcuts.help.searchFiles", "search-files": "settings.shortcuts.help.searchFiles",
"toggle-command-center": "settings.shortcuts.help.toggleCommandCenter", "toggle-command-center": "settings.shortcuts.help.toggleCommandCenter",
@@ -379,7 +389,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Tab management --- // --- Tab management ---
{ {
id: "workspace-tab-new-cmd-t-mac", id: "workspace-tab-new-cmd-t-mac",
action: "workspace.tab.new", action: "workspace.tab.menu.open",
combo: "Cmd+T", combo: "Cmd+T",
when: { mac: true, commandCenter: false }, when: { mac: true, commandCenter: false },
help: { help: {
@@ -390,7 +400,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
}, },
{ {
id: "workspace-tab-new-ctrl-t-non-mac", id: "workspace-tab-new-ctrl-t-non-mac",
action: "workspace.tab.new", action: "workspace.tab.menu.open",
combo: "Ctrl+T", combo: "Ctrl+T",
when: { mac: false, commandCenter: false, terminal: false }, when: { mac: false, commandCenter: false, terminal: false },
help: { help: {
@@ -399,6 +409,94 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
label: "New tab", 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", id: "workspace-tab-close-current-cmd-w-mac",
action: "workspace.tab.close.current", action: "workspace.tab.close.current",
@@ -737,6 +835,28 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
label: "Close pane", 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 --- // --- New terminal ---
{ {
@@ -28,7 +28,11 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRouti
describe("routeKeyboardShortcut — dispatch passthroughs", () => { describe("routeKeyboardShortcut — dispatch passthroughs", () => {
it.each([ it.each([
["agent.interrupt", { id: "agent.interrupt", scope: "global" }], ["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.new", { id: "workspace.new", scope: "sidebar" }],
["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }], ["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }], ["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.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", 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.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" }], ["view.toggle.focus", { id: "workspace.focus.toggle", scope: "workspace" }],
])("%s → dispatch %j", (action, expected) => { ])("%s → dispatch %j", (action, expected) => {
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({ 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. // Action ids whose routing is a no-payload pass-through to the dispatcher.
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = { const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" }, "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.new": { id: "workspace.new", scope: "sidebar" },
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" }, "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.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
"workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", 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.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" }, "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", () => { it("hands named keys to the display table under the names it uses", () => {
expect(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft")).toEqual(["mod", "shift", "Left"]); expect(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft")).toEqual(["mod", "shift", "Left"]);
expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe( expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe("⇧⌘←");
"Shift+⌘+←",
);
expect(formatShortcut(comboStringToShortcutKeys("Escape"), "mac")).toBe("Esc"); expect(formatShortcut(comboStringToShortcutKeys("Escape"), "mac")).toBe("Esc");
expect(formatShortcut(comboStringToShortcutKeys("Backspace"), "mac")).toBe("⌫"); expect(formatShortcut(comboStringToShortcutKeys("Backspace"), "mac")).toBe("⌫");
}); });
+33 -10
View File
@@ -122,7 +122,10 @@ function getFocusableElements(scope: HTMLElement): HTMLElement[] {
} }
function focusFirstElement(scope: HTMLElement): void { 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(); (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 top = getTopWebOverlay();
const scope = top?.getScope(); const scope = top?.getScope();
if (!top || !scope) return; if (!top || !scope) return false;
if (event.key === "Tab") { if (event.key === "Tab") {
const focusable = getFocusableElements(scope); const focusable = getFocusableElements(scope);
@@ -161,12 +168,17 @@ function handleWebOverlayKeyDown(event: KeyboardEvent): void {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
(shouldWrapBackward ? last : first)?.focus(); (shouldWrapBackward ? last : first)?.focus();
if (!first) scope.focus(); if (!first) scope.focus();
return; return true;
} }
} }
if (!top.getKeyHandler()(event)) return; if (!top.getKeyHandler()(event)) return false;
event.stopImmediatePropagation(); event.stopImmediatePropagation();
return true;
}
function handleWebOverlayKeyDown(event: KeyboardEvent): void {
dispatchTopWebOverlayKeyDown(event);
} }
function attachWebOverlayListeners(): void { function attachWebOverlayListeners(): void {
@@ -209,6 +221,7 @@ interface WebOverlayRegistration {
active: boolean; active: boolean;
layer: number; layer: number;
onKeyDown: WebOverlayKeyHandler; onKeyDown: WebOverlayKeyHandler;
restoreFocusRef?: React.RefObject<unknown>;
} }
/** /**
@@ -216,12 +229,17 @@ interface WebOverlayRegistration {
* Only the highest painted overlay receives keyboard input; focus is trapped * Only the highest painted overlay receives keyboard input; focus is trapped
* there and restored to the opener when that overlay closes. * 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 idRef = useRef(Symbol("web-overlay"));
const scopeRef = useRef<HTMLElement | null>(null); const scopeRef = useRef<HTMLElement | null>(null);
const layerRef = useRef(layer); const layerRef = useRef(layer);
const keyHandlerRef = useRef(onKeyDown); const keyHandlerRef = useRef(onKeyDown);
const restoreFocusRef = useRef<HTMLElement | null>(null); const capturedRestoreFocusRef = useRef<HTMLElement | null>(null);
const removeEntryRef = useRef<(() => void) | null>(null); const removeEntryRef = useRef<(() => void) | null>(null);
const activeRef = useRef(active); const activeRef = useRef(active);
const wasActiveRef = useRef(false); const wasActiveRef = useRef(false);
@@ -230,8 +248,13 @@ export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverl
layerRef.current = layer; layerRef.current = layer;
keyHandlerRef.current = onKeyDown; keyHandlerRef.current = onKeyDown;
if (active && !wasActiveRef.current && typeof document !== "undefined") { if (active && !wasActiveRef.current && typeof document !== "undefined") {
restoreFocusRef.current = const requestedRestoreTarget = preferredRestoreFocusRef?.current;
document.activeElement instanceof HTMLElement ? document.activeElement : null; if (requestedRestoreTarget instanceof HTMLElement) {
capturedRestoreFocusRef.current = requestedRestoreTarget;
} else {
capturedRestoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
} }
wasActiveRef.current = active; wasActiveRef.current = active;
@@ -254,7 +277,7 @@ export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverl
getLayer: () => layerRef.current, getLayer: () => layerRef.current,
getScope: () => scopeRef.current, getScope: () => scopeRef.current,
getKeyHandler: () => keyHandlerRef.current, getKeyHandler: () => keyHandlerRef.current,
restoreFocus: restoreFocusRef.current, restoreFocus: capturedRestoreFocusRef.current,
}; };
removeEntryRef.current = addWebOverlay(entry); removeEntryRef.current = addWebOverlay(entry);
}, []); }, []);
@@ -2,6 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useId,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
@@ -11,7 +12,6 @@ import React, {
} from "react"; } from "react";
import { import {
Pressable, Pressable,
ScrollView,
Text, Text,
View, View,
type LayoutChangeEvent, type LayoutChangeEvent,
@@ -28,12 +28,21 @@ import {
FileDiff, FileDiff,
FolderTree, FolderTree,
GitPullRequest, GitPullRequest,
Maximize2,
Minimize2,
Plus, Plus,
SquarePen, SquarePen,
SquareTerminal, SquareTerminal,
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; 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 { useTranslation } from "react-i18next";
import { useRouter, type Href } from "expo-router"; import { useRouter, type Href } from "expo-router";
import { SortableInlineList } from "@/components/sortable-inline-list"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/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 { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; 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 DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
// Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes // 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 // shape without changing them mis-measures and drops the row into the overflow-scroll fallback at
// the wrong width. Keep them together. // 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_HORIZONTAL_PADDING = 8;
const TAB_CHIP_GAP = 4; const TAB_CHIP_GAP = 4;
const TAB_ROW_PADDING_HORIZONTAL = 4; const TAB_ROW_PADDING_HORIZONTAL = 4;
@@ -110,10 +123,68 @@ const TAB_MIN_WIDTH = 96;
const TAB_MAX_WIDTH = 160; const TAB_MAX_WIDTH = 160;
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
const TAB_LABEL_LAYOUT_ALLOWANCE = 4; const TAB_LABEL_LAYOUT_ALLOWANCE = 4;
const TAB_SCROLL_SHADE_WIDTH = 24;
const TAB_SCROLL_EDGE_EPSILON = 1;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
const ThemedCopy = withUnistyles(Copy); 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 ThemedRotateCw = withUnistyles(RotateCw);
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
@@ -126,6 +197,8 @@ const ThemedPlus = withUnistyles(Plus);
const ThemedFileDiff = withUnistyles(FileDiff); const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree); const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedMaximize2 = withUnistyles(Maximize2);
const ThemedMinimize2 = withUnistyles(Minimize2);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); 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 FILES_TARGET = { kind: "files" } as const;
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { function inlineAddActionButtonStyle({
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; hovered,
pressed,
open = false,
}: PressableStateCallbackType & { open?: boolean }) {
return [
styles.inlineAddActionButton,
(hovered || pressed || open) && styles.newTabActionButtonHovered,
];
} }
function updateMeasuredWidth( function updateMeasuredWidth(
@@ -210,6 +290,8 @@ interface TabTargetLauncherOptions {
} }
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcutKeys: ShortcutKey[][] | null; shortcutKeys: ShortcutKey[][] | null;
onEditProfiles: () => void; onEditProfiles: () => void;
showCreateBrowserTab: boolean; showCreateBrowserTab: boolean;
@@ -220,6 +302,8 @@ interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
} }
function WorkspaceNewTabButton({ function WorkspaceNewTabButton({
open,
onOpenChange,
shortcutKeys, shortcutKeys,
onCreateAgentTab, onCreateAgentTab,
onCreateTerminal, onCreateTerminal,
@@ -242,15 +326,36 @@ function WorkspaceNewTabButton({
() => resolveTerminalProfiles(config?.terminalProfiles), () => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles], [config?.terminalProfiles],
); );
const shortcut = useMemo( const agentKeys = useShortcutKeys("workspace-tab-target-agent");
() => (shortcutKeys ? <Shortcut chord={shortcutKeys} /> : undefined), const terminalKeys = useShortcutKeys("workspace-terminal-new");
[shortcutKeys], 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 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 ( return (
<View style={styles.inlineAddButton} onLayout={onLayout}> <View style={styles.inlineAddButton} onLayout={onLayout}>
<DropdownMenu> <DropdownMenu open={open} onOpenChange={onOpenChange}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef"> <TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -263,14 +368,17 @@ function WorkspaceNewTabButton({
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <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> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}> <DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<DropdownMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-agent" testID="workspace-new-tab-menu-agent"
leading={AGENT_ICON} leading={AGENT_ICON}
trailing={shortcut} trailing={agentShortcut}
onSelect={onCreateAgentTab} onSelect={onCreateAgentTab}
> >
{t("workspace.tabs.actions.newAgent")} {t("workspace.tabs.actions.newAgent")}
@@ -279,6 +387,7 @@ function WorkspaceNewTabButton({
testID="workspace-new-tab-menu-terminal" testID="workspace-new-tab-menu-terminal"
leading={TERMINAL_ICON} leading={TERMINAL_ICON}
disabled={terminalDisabled} disabled={terminalDisabled}
trailing={terminalShortcut}
onSelect={terminalDisabled ? undefined : onCreateTerminal} onSelect={terminalDisabled ? undefined : onCreateTerminal}
> >
{t("workspace.tabs.actions.newTerminal")} {t("workspace.tabs.actions.newTerminal")}
@@ -287,6 +396,7 @@ function WorkspaceNewTabButton({
<DropdownMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-browser" testID="workspace-new-tab-menu-browser"
leading={BROWSER_ICON} leading={BROWSER_ICON}
trailing={browserShortcut}
onSelect={onCreateBrowser} onSelect={onCreateBrowser}
> >
{t("workspace.tabs.actions.newBrowser")} {t("workspace.tabs.actions.newBrowser")}
@@ -296,6 +406,7 @@ function WorkspaceNewTabButton({
<DropdownMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-changes" testID="workspace-new-tab-menu-changes"
leading={CHANGES_ICON} leading={CHANGES_ICON}
trailing={changesShortcut}
onSelect={onOpenChanges} onSelect={onOpenChanges}
> >
{t("workspace.tabs.actions.changes")} {t("workspace.tabs.actions.changes")}
@@ -304,6 +415,7 @@ function WorkspaceNewTabButton({
<DropdownMenuItem <DropdownMenuItem
testID="workspace-new-tab-menu-files" testID="workspace-new-tab-menu-files"
leading={FILES_ICON} leading={FILES_ICON}
trailing={filesShortcut}
onSelect={onOpenFiles} onSelect={onOpenFiles}
> >
{t("workspace.tabs.actions.files")} {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({ function TabContextMenuItem({
entry, entry,
}: { }: {
@@ -464,6 +669,9 @@ interface WorkspaceDesktopTabsRowProps {
externalDndContext?: boolean; externalDndContext?: boolean;
activeDragTabId?: string | null; activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null; tabDropPreviewIndex?: number | null;
showPaneMaximizeAction?: boolean;
paneMaximized?: boolean;
onTogglePaneMaximized?: () => void;
focusModeEnabled: boolean; focusModeEnabled: boolean;
onExitFocusMode: () => void; onExitFocusMode: () => void;
} }
@@ -942,16 +1150,23 @@ function ResolvedWorkspaceDesktopTabsRow({
externalDndContext = false, externalDndContext = false,
activeDragTabId = null, activeDragTabId = null,
tabDropPreviewIndex = null, tabDropPreviewIndex = null,
showPaneMaximizeAction = false,
paneMaximized = false,
onTogglePaneMaximized,
focusModeEnabled, focusModeEnabled,
onExitFocusMode, onExitFocusMode,
}: ResolvedWorkspaceDesktopTabsRowProps) { }: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const focusModeKeys = useShortcutKeys("toggle-focus");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = 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 labelMeasurementsRef = useRef(new Map<string, WorkspaceTabLabelMeasurement>());
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null); const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
@@ -980,13 +1195,48 @@ function ResolvedWorkspaceDesktopTabsRow({
updateMeasuredWidth(setExitFocusModeWidth, event); 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( const layoutMetrics = useMemo(
() => ({ () => ({
rowHorizontalInset: 0, rowHorizontalInset: 0,
actionsReservedWidth: Math.max( actionsReservedWidth: Math.max(
0, 0,
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0), (focusModeEnabled ? exitFocusModeWidth : 0) +
resolvePaneMaximizeReservedWidth(showPaneMaximizeAction, paneMaximizeButtonWidth),
), ),
rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
tabGap: TAB_CHIP_GAP, tabGap: TAB_CHIP_GAP,
@@ -997,7 +1247,13 @@ function ResolvedWorkspaceDesktopTabsRow({
tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING,
closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH,
}), }),
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth], [
exitFocusModeWidth,
focusModeEnabled,
inlineAddButtonWidth,
paneMaximizeButtonWidth,
showPaneMaximizeAction,
],
); );
const fallbackTabLabels = useMemo( const fallbackTabLabels = useMemo(
@@ -1175,6 +1431,67 @@ function ResolvedWorkspaceDesktopTabsRow({
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; 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( const renderTab = useCallback(
({ ({
item, item,
@@ -1276,72 +1593,67 @@ function ResolvedWorkspaceDesktopTabsRow({
/> />
))} ))}
</View> </View>
{focusModeEnabled ? ( <WorkspaceExitFocusModeButton
<View style={styles.exitFocusModeSlot} onLayout={handleExitFocusModeLayout}> visible={focusModeEnabled}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> onPress={onExitFocusMode}
<TooltipTrigger onLayout={handleExitFocusModeLayout}
testID="workspace-exit-focus-mode" />
onPress={onExitFocusMode} <View style={styles.tabsScrollContainer}>
accessibilityRole="button" <Animated.ScrollView
accessibilityLabel={t("workspace.tabs.actions.exitFocusMode")} horizontal
style={inlineAddActionButtonStyle} scrollEnabled={layout.requiresHorizontalScrollFallback}
> testID="workspace-tabs-scroll"
<ThemedX size={14} uniProps={mutedColorMapping} /> style={tabsScrollStyle}
</TooltipTrigger> contentContainerStyle={styles.tabsContent}
<TooltipContent side="bottom" align="center" offset={8}> showsHorizontalScrollIndicator={false}
<View style={styles.newTabTooltipRow}> onLayout={handleTabScrollLayout}
<Text style={styles.newTabTooltipText}> onContentSizeChange={handleTabScrollContentSizeChange}
{t("workspace.tabs.actions.exitFocusMode")} onScroll={handleTabScroll}
</Text> scrollEventThrottle={16}
{focusModeKeys ? ( >
<Shortcut chord={focusModeKeys} style={styles.newTabTooltipShortcut} /> <SortableInlineList
) : null} data={displayedTabs}
</View> keyExtractor={tabKeyExtractor}
</TooltipContent> useDragHandle
</Tooltip> disabled={!externalDndContext && displayedTabs.length < 2}
</View> onDragEnd={handleDragEnd}
) : null} externalDndContext={externalDndContext}
<ScrollView activeId={activeDragTabId}
horizontal getItemData={getTabDragData}
scrollEnabled={layout.requiresHorizontalScrollFallback} renderItem={renderTab}
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}
/> />
) : null} {!layout.requiresHorizontalScrollFallback ? (
</ScrollView> <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 ? ( {layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton <WorkspaceNewTabButton
open={newTabMenuOpen}
onOpenChange={setNewTabMenuOpen}
shortcutKeys={newTabKeys} shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab} onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal} onCreateTerminal={handleCreateTerminal}
@@ -1359,6 +1671,12 @@ function ResolvedWorkspaceDesktopTabsRow({
onLayout={handleInlineAddButtonLayout} onLayout={handleInlineAddButtonLayout}
/> />
) : null} ) : null}
<WorkspacePaneMaximizeButton
visible={showPaneMaximizeAction}
maximized={paneMaximized}
onPress={onTogglePaneMaximized}
onLayout={handlePaneMaximizeButtonLayout}
/>
</View> </View>
); );
@@ -1502,6 +1820,11 @@ const styles = StyleSheet.create((theme) => ({
tabsScroll: { tabsScroll: {
minWidth: 0, minWidth: 0,
}, },
tabsScrollContainer: {
minWidth: 0,
flex: 1,
alignSelf: "stretch",
},
tabsScrollFitContent: { tabsScrollFitContent: {
flex: 1, flex: 1,
}, },
@@ -1513,6 +1836,18 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
}, },
tabScrollShade: {
position: "absolute",
top: 0,
bottom: 1,
width: TAB_SCROLL_SHADE_WIDTH,
},
tabScrollShadeLeft: {
left: 0,
},
tabScrollShadeRight: {
right: 0,
},
exitFocusModeSlot: { exitFocusModeSlot: {
alignSelf: "stretch", alignSelf: "stretch",
flexDirection: "row", flexDirection: "row",
@@ -1526,8 +1861,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
paddingHorizontal: theme.spacing[1], 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: { tab: {
height: TAB_CHIP_HEIGHT, height: buttonControlHeight.xs,
paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING, paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
flexDirection: "row", flexDirection: "row",
@@ -1661,11 +2000,13 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.foregroundMuted, backgroundColor: theme.colors.foregroundMuted,
}, },
inlineAddActionButton: { inlineAddActionButton: {
width: 28, width: buttonControlHeight.xs,
height: 28, height: buttonControlHeight.xs,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
outlineWidth: 0,
outlineColor: "transparent",
}, },
newTabActionButtonHovered: { newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -3147,7 +3147,7 @@ function WorkspaceScreenContent({
const handleWorkspaceTabAction = useCallback( const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
switch (action.id) { switch (action.id) {
case "workspace.tab.new": case "workspace.agent.new":
handleCreateDraftTab(); handleCreateDraftTab();
return true; return true;
case "workspace.terminal.new": case "workspace.terminal.new":
@@ -3298,7 +3298,7 @@ function WorkspaceScreenContent({
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`, handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: [ actions: [
"workspace.tab.new", "workspace.agent.new",
"workspace.tab.close-current", "workspace.tab.close-current",
"workspace.tab.navigate-index", "workspace.tab.navigate-index",
"workspace.tab.navigate-relative", "workspace.tab.navigate-relative",
@@ -8,9 +8,9 @@ describe("formatShortcut", () => {
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E"); expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
}); });
it("spells out Shift in shortcut labels", () => { it("uses the Shift symbol on macOS and spells it out elsewhere", () => {
expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab"); expect(formatShortcut(["shift", "Tab"], "mac")).toBe("⇧Tab");
expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P"); expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("⇧⌘P");
expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab"); 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 order = ["ctrl", "alt", "shift", "mod", "meta"];
const symbols: Record<string, string> = { const symbols: Record<string, string> = {
mod: "⌘", mod: "⌘",
shift: "⇧",
alt: "⌥", alt: "⌥",
ctrl: "⌃", ctrl: "⌃",
meta: "⌘", meta: "⌘",
}; };
const modifierSet = new Set(normalized); const modifierSet = new Set(normalized);
const mods = order const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
.filter((k) => modifierSet.has(k))
.map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? "")));
const main = normalized const main = normalized
.filter((k) => !order.includes(k)) .filter((k) => !order.includes(k))
.map(normalizeKey) .map(normalizeKey)
.join(""); .join("");
if (mods.includes("Shift")) {
return [...mods, main].filter(Boolean).join("+");
}
return `${mods.join("")}${main}`; return `${mods.join("")}${main}`;
} }