Make the command center stable and workspace-aware (#2749)

* feat(app): expand command center actions

Keep command center navigation stable by revealing only clipped rows without smooth centering. Prioritize a concise default list while making workspace, Git, pane, and creation actions searchable through the existing contribution and action-policy paths.

* test(app): search for query-only command action

Add project is intentionally absent from the unfiltered command center. Exercise the overlay flow through search before selecting it.
This commit is contained in:
Mohamed Boudra
2026-08-01 15:38:07 +02:00
committed by GitHub
parent 47dcb64ac7
commit fb9f7fb98a
21 changed files with 1178 additions and 247 deletions
@@ -0,0 +1,53 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
expectCommandCenterToRemainAtTheTop,
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectWorkspaceResultsToBeLimitedUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "./helpers/command-center-scroll";
import { seedWorkspace } from "./helpers/seed-client";
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("opening the command center keeps its first result fully visible", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-scroll-",
title: "Command Center Scroll 00",
});
try {
for (let index = 1; index <= 8; index += 1) {
const created = await seeded.client.createWorkspace({
source: {
kind: "worktree",
projectId: seeded.projectId,
baseBranch: "main",
worktreeSlug: `command-center-scroll-${index}`,
},
title: `Command Center Scroll ${String(index).padStart(2, "0")}`,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
}
}
await gotoAppShell(page);
await observeCommandCenterScroll(page);
const panel = await openCommandCenterWithKeyboard(page);
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
}
});
@@ -0,0 +1,34 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { openCommandCenter } from "./helpers/command-center";
import {
createTerminalFromCommandCenter,
expectDefaultWorkspaceActions,
expectWorkspaceActionsAvailableThroughSearch,
makeWorkspacePrimaryGitActionCommit,
openWorkspaceFromCommandCenter,
} from "./helpers/command-center-workspace-actions";
import { seedWorkspace } from "./helpers/seed-client";
test("workspace actions use the Git policy and dispatch through the active workspace", async ({
page,
}) => {
const title = "Command Center Workspace Actions";
const seeded = await seedWorkspace({
repoPrefix: "command-center-workspace-actions-",
title,
});
try {
await makeWorkspacePrimaryGitActionCommit(seeded);
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const panel = await openCommandCenter(page);
await expectDefaultWorkspaceActions(panel);
await expectWorkspaceActionsAvailableThroughSearch(panel);
await createTerminalFromCommandCenter(page, panel);
} finally {
await seeded.cleanup();
}
});
@@ -0,0 +1,167 @@
import { expect, type Locator, type Page } from "@playwright/test";
interface CommandCenterScrollSample {
scrollTop: number;
firstResultTop: number;
viewportTop: number;
}
interface CommandCenterScrollObservation {
done: boolean;
samples: CommandCenterScrollSample[];
}
type ScrollOracleWindow = Window & {
__commandCenterScrollObservation?: CommandCenterScrollObservation;
};
export async function observeCommandCenterScroll(page: Page): Promise<void> {
await page.evaluate(() => {
const oracleWindow = window as ScrollOracleWindow;
oracleWindow.__commandCenterScrollObservation = { done: false, samples: [] };
const discoverPanel = new MutationObserver(() => {
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
if (!panel || !firstResult) return;
let viewport = firstResult.parentElement;
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport || viewport === panel) return;
discoverPanel.disconnect();
let frame = 0;
const sample = () => {
const observation = oracleWindow.__commandCenterScrollObservation;
if (!observation) return;
const firstResultBounds = firstResult.getBoundingClientRect();
const viewportBounds = viewport.getBoundingClientRect();
observation.samples.push({
scrollTop: viewport.scrollTop,
firstResultTop: firstResultBounds.top,
viewportTop: viewportBounds.top,
});
frame += 1;
if (frame < 60) {
requestAnimationFrame(sample);
return;
}
observation.done = true;
};
requestAnimationFrame(sample);
});
discoverPanel.observe(document.body, { childList: true, subtree: true });
});
}
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Meta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<void> {
await page.waitForFunction(
() => (window as ScrollOracleWindow).__commandCenterScrollObservation?.done === true,
);
const samples = await page.evaluate(
() => (window as ScrollOracleWindow).__commandCenterScrollObservation?.samples ?? [],
);
expect(samples.length).toBeGreaterThan(0);
expect(
Math.min(...samples.map((sample) => sample.firstResultTop - sample.viewportTop)),
).toBeGreaterThanOrEqual(0);
expect(Math.max(...samples.map((sample) => sample.scrollTop))).toBe(0);
}
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
const expectedActions = [
{ title: "New workspace", shortcut: "⌘N" },
{ title: "History" },
{ title: "Schedules" },
{ title: "Settings", shortcut: "⌘," },
{ title: "Keyboard shortcuts", shortcut: "?" },
] as const;
for (const action of expectedActions) {
const row = panel.getByRole("button").filter({ hasText: action.title });
await expect(row).toBeVisible();
if ("shortcut" in action) await expect(row).toContainText(action.shortcut);
}
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
const input = panel.getByTestId("command-center-input");
await input.fill("add project");
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toBeVisible();
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
await input.fill("home");
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
await input.fill("");
}
export async function expectVisibleKeyboardNavigationNotToScroll(
page: Page,
panel: Locator,
): Promise<void> {
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
const scrollSamples = await panel
.getByRole("button")
.first()
.evaluate(async (firstResult) => {
let viewport = firstResult.parentElement;
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport) throw new Error("Could not find the command center scroll viewport");
const samples: number[] = [];
for (let frame = 0; frame < 20; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(viewport.scrollTop);
}
return samples;
});
expect(Math.max(...scrollSamples)).toBe(0);
}
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
const workspaces = panel.locator(
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
);
const distinctWorkspaceCount = async () =>
new Set(
await workspaces.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
),
).size;
await expect.poll(distinctWorkspaceCount).toBe(5);
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
await expect.poll(distinctWorkspaceCount).toBe(9);
}
export async function expectTightTwoLineResultSpacing(
panel: Locator,
title: string,
): Promise<void> {
const row = panel.getByText(title, { exact: true }).locator("..");
const titleTop = await row
.getByText(title, { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const subtitleTop = await row
.getByTestId("command-center-workspace-subtitle")
.evaluate((element) => element.getBoundingClientRect().top);
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
}
@@ -0,0 +1,66 @@
import { writeFileSync } from "node:fs";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { openCommandCenter } from "./command-center";
import { expectAppRoute } from "./route-assertions";
import type { SeededWorkspace } from "./seed-client";
import { getServerId } from "./server-id";
import { expectTerminalTabOpen } from "./workspace-tabs";
function action(panel: Locator, title: string): Locator {
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return panel.getByRole("button", { name: new RegExp(`^${escapedTitle}(?:\\s|$)`) });
}
export async function makeWorkspacePrimaryGitActionCommit(seeded: SeededWorkspace): Promise<void> {
writeFileSync(path.join(seeded.repoPath, "command-center-dirty.txt"), "dirty\n");
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
}
}
export async function openWorkspaceFromCommandCenter(
page: Page,
seeded: SeededWorkspace,
title: string,
): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
}
export async function expectDefaultWorkspaceActions(panel: Locator): Promise<void> {
await expect(panel.getByText("Workspace actions", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(action(panel, "New agent")).toBeVisible();
await expect(action(panel, "Commit")).toBeVisible();
await expect(action(panel, "Push")).toHaveCount(0);
await expect(action(panel, "New terminal")).toHaveCount(0);
await expect(action(panel, "Split pane right")).toHaveCount(0);
await expect(action(panel, "Home")).toHaveCount(0);
await expect(action(panel, "Add project")).toHaveCount(0);
}
export async function expectWorkspaceActionsAvailableThroughSearch(panel: Locator): Promise<void> {
const input = panel.getByTestId("command-center-input");
await input.fill("push");
await expect(action(panel, "Push")).toBeVisible();
await input.fill("home");
await expect(action(panel, "Home")).toBeVisible();
await input.fill("git");
await expect.poll(() => panel.getByRole("button").count()).toBeGreaterThan(1);
}
export async function createTerminalFromCommandCenter(page: Page, panel: Locator): Promise<void> {
await panel.getByTestId("command-center-input").fill("terminal");
await page.keyboard.press("Enter");
await expectTerminalTabOpen(page);
}
@@ -156,6 +156,7 @@ test.describe("provider settings overlay stack", () => {
await page.keyboard.press("ControlOrMeta+K"); await page.keyboard.press("ControlOrMeta+K");
await expect(commandCenter).toBeVisible({ timeout: 10_000 }); await expect(commandCenter).toBeVisible({ timeout: 10_000 });
await commandCenter.getByTestId("command-center-input").fill("add project");
await commandCenter.getByText("Add project", { exact: true }).click(); await commandCenter.getByText("Add project", { exact: true }).click();
const addProject = page.getByTestId("add-project-flow"); const addProject = page.getByTestId("add-project-flow");
await expect(addProject).toBeVisible({ timeout: 10_000 }); await expect(addProject).toBeVisible({ timeout: 10_000 });
+4 -1
View File
@@ -21,8 +21,10 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center"; import { CommandCenter } from "@/command-center/command-center";
import { CommandCenterRootActions } from "@/command-center/root-registration";
import { CommandCenterProvider } from "@/command-center/provider"; import { CommandCenterProvider } from "@/command-center/provider";
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
import { AddProjectFlowHost } from "@/components/add-project-flow-host"; import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast"; import { DownloadToast } from "@/components/download-toast";
@@ -555,6 +557,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<UpdateCalloutSource /> <UpdateCalloutSource />
<WorktreeSetupCalloutSource /> <WorktreeSetupCalloutSource />
<CommandCenterRootActions /> <CommandCenterRootActions />
<CommandCenterWorkspaceActions />
<WorkspacePinShortcutHandler /> <WorkspacePinShortcutHandler />
<CommandCenter /> <CommandCenter />
<AddProjectFlowHost /> <AddProjectFlowHost />
@@ -5,13 +5,15 @@ import {
Text, Text,
TextInput, TextInput,
View, View,
type LayoutChangeEvent,
type ListRenderItemInfo, type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native"; import { Check, ChevronRight, Folder } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -25,11 +27,9 @@ import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { import {
OverlayLayerProvider, OverlayLayerProvider,
@@ -39,22 +39,16 @@ import {
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { useCommandCenterContributions } from "./provider";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
import { import {
buildContributionSections, buildContributionSections,
joinSubtitleParts, joinSubtitleParts,
@@ -79,113 +73,9 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
})); }));
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedSettings = withUnistyles(Settings, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
}
function SettingsIcon({ size }: CommandCenterIconProps) {
return <ThemedSettings size={size} strokeWidth={2.2} />;
}
function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const platform = {
isMac: getShortcutOs() === "mac",
isDesktop: getIsElectronRuntime(),
};
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
}
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
const actions = useMemo<CommandCenterContribution[]>(
() => [
{
id: "new-agent",
group: "actions",
groupRank: 0,
rank: 0,
keywords: ["open", "project", "folder", "workspace", "repo"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
openAddProject();
},
presentation: {
kind: "action",
title: t("shell.commandCenter.addProject"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PlusIcon,
shortcutKeys: resolveActionShortcutKeys("new-agent", overrides),
},
},
{
id: "home",
group: "actions",
groupRank: 0,
rank: 1,
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(homeRoute);
},
presentation: {
kind: "action",
title: t("shell.commandCenter.home"),
sectionTitle: t("shell.commandCenter.actions"),
icon: HomeIcon,
},
},
{
id: "settings",
group: "actions",
groupRank: 0,
rank: 2,
keywords: ["settings", "preferences", "config", "configuration"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(settingsRoute);
},
presentation: {
kind: "action",
title: t("sidebar.actions.settings"),
sectionTitle: t("shell.commandCenter.actions"),
icon: SettingsIcon,
},
},
],
[homeRoute, openAddProject, overrides, settingsRoute, t],
);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
@@ -205,6 +95,10 @@ function matchesQuery(searchText: string, query: string): boolean {
return !normalized || searchText.includes(normalized); return !normalized || searchText.includes(normalized);
} }
function limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation(); const { t } = useTranslation();
const { agents } = useAggregatedAgents(); const { agents } = useAggregatedAgents();
@@ -249,10 +143,12 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
const workspaceTitleByKey = new Map( const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
); );
const workspaces = allWorkspaces.filter((workspace) => const workspaces = limitDefaultCategoryResults(
matchesQuery(workspace.searchText, query), allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
query,
); );
const agentResults = agents const agentResults = limitDefaultCategoryResults(
agents
.map<CommandCenterAgentResult>((agent) => { .map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent"); const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId const workspaceTitle = agent.workspaceId
@@ -278,7 +174,9 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
}; };
}) })
.filter((agent) => matchesQuery(agent.searchText, query)) .filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent)); .sort((left, right) => sortAgents(left.agent, right.agent)),
query,
);
return [ return [
{ {
id: "workspaces", id: "workspaces",
@@ -570,19 +468,54 @@ export function CommandCenter() {
const listRef = useRef<FlatList<CommandCenterListRow>>(null); const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null); const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null); const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open, visible: state.open,
isEnabled: showBottomSheet, isEnabled: showBottomSheet,
onClose: state.close, onClose: state.close,
}); });
useEffect(() => { const revealActiveResult = useCallback(() => {
if (!state.open || !state.activeId) return; if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId); const index = state.rowIndexByResultId.get(state.activeId);
if (index === undefined) return; if (index === undefined) return;
const { offset, visibleLength } = scrollMetricsRef.current;
if (visibleLength <= 0) return;
const rowTop = state.offsets[index];
const rowBottom = rowTop + state.rows[index].height;
let nextOffset: number | null = null;
if (rowTop < offset) nextOffset = rowTop;
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
if (nextOffset === null) return;
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 }); const boundedOffset = Math.max(0, nextOffset);
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]); scrollMetricsRef.current.offset = boundedOffset;
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
}, [
showBottomSheet,
state.activeId,
state.offsets,
state.open,
state.rowIndexByResultId,
state.rows,
]);
useEffect(() => {
if (!state.open) {
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
return;
}
revealActiveResult();
}, [revealActiveResult, state.open]);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
revealActiveResult();
},
[revealActiveResult],
);
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
}, []);
useEffect(() => { useEffect(() => {
if (!showBottomSheet || !state.open) return; if (!showBottomSheet || !state.open) return;
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
@@ -626,6 +559,9 @@ export function CommandCenter() {
initialNumToRender: 12, initialNumToRender: 12,
maxToRenderPerBatch: 10, maxToRenderPerBatch: 10,
windowSize: 5, windowSize: 5,
onLayout: handleListLayout,
onScroll: handleListScroll,
scrollEventThrottle: 16,
}; };
const keyPress = useCallback( const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key), ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
@@ -786,10 +722,10 @@ const styles = StyleSheet.create((theme) => ({
title: { title: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
lineHeight: 20, lineHeight: 18,
flexShrink: 1, flexShrink: 1,
}, },
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 }, subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
rowShortcut: { flexShrink: 0 }, rowShortcut: { flexShrink: 0 },
breadcrumb: { breadcrumb: {
@@ -72,6 +72,16 @@ describe("Command Center result projection", () => {
expect(projection.offsets).toEqual([0, 32, 68, 117]); expect(projection.offsets).toEqual([0, 32, 68, 117]);
}); });
it("shows query-only actions only when their group matches a search", () => {
const contributions = [
contribution({ id: "settings", group: "actions", groupRank: 0 }),
contribution({ id: "home", group: "actions", groupRank: 0, visibility: "query" }),
];
expect(sectionResultIds(buildContributionSections(contributions, ""))).toEqual(["settings"]);
expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]);
});
it("preserves active selection by id and falls back to the first result", () => { it("preserves active selection by id and falls back to the first result", () => {
const first = workspace("workspace:first"); const first = workspace("workspace:first");
const second = workspace("workspace:second"); const second = workspace("workspace:second");
@@ -0,0 +1,241 @@
import { useMemo } from "react";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import {
CalendarClock,
FolderPlus,
History,
Home,
Keyboard,
Plus,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildOpenProjectRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsRoute,
} from "@/utils/host-routes";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
import { useCommandCenterActions } from "./provider";
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHistory = withUnistyles(History, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedCalendarClock = withUnistyles(CalendarClock, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedKeyboard = withUnistyles(Keyboard, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedSettings = withUnistyles(Settings, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
}
function AddProjectIcon({ size }: CommandCenterIconProps) {
return <ThemedFolderPlus size={size} strokeWidth={2.2} />;
}
function SettingsIcon({ size }: CommandCenterIconProps) {
return <ThemedSettings size={size} strokeWidth={2.2} />;
}
function HistoryIcon({ size }: CommandCenterIconProps) {
return <ThemedHistory size={size} strokeWidth={2.2} />;
}
function SchedulesIcon({ size }: CommandCenterIconProps) {
return <ThemedCalendarClock size={size} strokeWidth={2.2} />;
}
function KeyboardIcon({ size }: CommandCenterIconProps) {
return <ThemedKeyboard size={size} strokeWidth={2.2} />;
}
function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
const schedulesRoute = useMemo<Href>(() => buildSchedulesRoute(), []);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
);
const actions = useMemo<CommandCenterContribution[]>(
() => [
{
id: "add-project",
group: "actions",
groupRank: 0,
rank: 0,
keywords: ["open", "project", "folder", "workspace", "repo"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
openAddProject();
},
presentation: {
kind: "action",
title: t("shell.commandCenter.addProject"),
sectionTitle: t("shell.commandCenter.actions"),
icon: AddProjectIcon,
// The help id remains "new-agent" because user overrides are keyed by its binding id.
shortcutKeys:
resolveShortcutKeysForAction("new-agent", overrides, shortcutPlatform) ?? undefined,
},
},
{
id: "new-workspace",
group: "actions",
groupRank: 0,
rank: 1,
keywords: ["new", "workspace", "worktree", "branch"],
visibility: "always",
run: () => {
keyboardActionDispatcher.dispatch({ id: "workspace.new", scope: "sidebar" });
},
presentation: {
kind: "action",
title: t("sidebar.actions.newWorkspace"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PlusIcon,
shortcutKeys:
resolveShortcutKeysForAction("new-workspace", overrides, shortcutPlatform) ?? undefined,
},
},
{
id: "home",
group: "actions",
groupRank: 0,
rank: 2,
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(homeRoute);
},
presentation: {
kind: "action",
title: t("shell.commandCenter.home"),
sectionTitle: t("shell.commandCenter.actions"),
icon: HomeIcon,
},
},
{
id: "history",
group: "actions",
groupRank: 0,
rank: 3,
keywords: ["history", "sessions", "recent"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(sessionsRoute);
},
presentation: {
kind: "action",
title: t("sidebar.sections.sessions"),
sectionTitle: t("shell.commandCenter.actions"),
icon: HistoryIcon,
},
},
{
id: "schedules",
group: "actions",
groupRank: 0,
rank: 4,
keywords: ["schedules", "scheduled", "automation", "recurring"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(schedulesRoute);
},
presentation: {
kind: "action",
title: t("sidebar.sections.schedules"),
sectionTitle: t("shell.commandCenter.actions"),
icon: SchedulesIcon,
},
},
{
id: "settings",
group: "actions",
groupRank: 0,
rank: 5,
keywords: ["settings", "preferences", "config", "configuration"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(settingsRoute);
},
presentation: {
kind: "action",
title: t("sidebar.actions.settings"),
sectionTitle: t("shell.commandCenter.actions"),
icon: SettingsIcon,
shortcutKeys:
resolveShortcutKeysForAction("toggle-settings", overrides, shortcutPlatform) ??
undefined,
},
},
{
id: "keyboard-shortcuts",
group: "actions",
groupRank: 0,
rank: 6,
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
visibility: "always",
run: () => setShortcutsDialogOpen(true),
presentation: {
kind: "action",
title: t("sidebar.help.shortcuts"),
sectionTitle: t("shell.commandCenter.actions"),
icon: KeyboardIcon,
shortcutKeys:
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
undefined,
},
},
],
[
homeRoute,
openAddProject,
overrides,
schedulesRoute,
sessionsRoute,
setShortcutsDialogOpen,
settingsRoute,
shortcutPlatform,
t,
],
);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterSource,
} from "./workspace-contributions";
function gitAction(id: GitAction["id"], label: string): GitAction {
return {
id,
label,
pendingLabel: `${label} pending`,
successLabel: `${label} complete`,
disabled: false,
status: "idle",
startsGroup: false,
handler: () => undefined,
};
}
function source(gitActions: GitActions): {
value: WorkspaceCommandCenterSource;
runGitActions: GitAction[];
dispatched: KeyboardActionDefinition[];
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
return {
value: {
gitActions,
labels: {
section: "Workspace actions",
newAgent: "New agent",
newTerminal: "New terminal",
newBrowser: "New browser",
splitRight: "Split pane right",
splitDown: "Split pane down",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
runGitActions,
dispatched,
};
}
describe("workspace command center contributions", () => {
it("makes only the policy-selected primary Git action default-visible and runs it", () => {
const primary = gitAction("commit", "Commit");
const fixture = source({
primary,
secondary: [gitAction("push", "Push")],
menu: [],
});
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const gitContributions = contributions.filter((item) => item.id.startsWith("git:"));
const defaultGitContributions = gitContributions.filter((item) => item.visibility === "always");
expect(defaultGitContributions.map((item) => item.id)).toEqual(["git:commit"]);
defaultGitContributions[0].run();
expect(fixture.runGitActions).toEqual([primary]);
});
it("does not duplicate a primary action retained in the secondary policy list", () => {
const primary = gitAction("pull", "Pull");
const fixture = source({
primary,
secondary: [primary, gitAction("push", "Push")],
menu: [],
});
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.filter((item) => item.id === "git:pull")).toHaveLength(1);
});
it("orders New agent before Git and keeps terminal, browser, and splits search-only", () => {
const fixture = source({
primary: gitAction("commit", "Commit"),
secondary: [],
menu: [],
});
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
]);
});
it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
});
it("dispatches every tab and pane command to the workspace scope", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.tab.new", scope: "workspace" },
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
});
it("keeps workspace creation commands available outside Git", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -0,0 +1,175 @@
import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface WorkspaceCommandCenterLabels {
section: string;
newAgent: string;
newTerminal: string;
newBrowser: string;
splitRight: string;
splitDown: string;
}
export interface WorkspaceCommandCenterIcons {
newAgent?: CommandCenterIcon;
newTerminal?: CommandCenterIcon;
newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
export interface WorkspaceCommandCenterShortcuts {
newAgent?: ShortcutKey[][];
newTerminal?: ShortcutKey[][];
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
gitActions: GitActions;
labels: WorkspaceCommandCenterLabels;
icons: WorkspaceCommandCenterIcons;
shortcuts: WorkspaceCommandCenterShortcuts;
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
};
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
}
function buildGitContribution(
source: WorkspaceCommandCenterSource,
action: GitAction,
rank: number,
visibility: "always" | "query",
): CommandCenterContribution {
return {
id: `git:${action.id}`,
group: "workspace",
groupRank: -1,
rank,
keywords: [action.id, "git"],
visibility,
run: () => source.runGitAction(action),
presentation: {
kind: "action",
title: action.label,
sectionTitle: source.labels.section,
icon: source.icons.git?.(action),
shortcutKeys:
action.id === "archive-workspace" ? source.shortcuts.archiveWorkspace : undefined,
},
};
}
function buildWorkspaceAction(input: {
source: WorkspaceCommandCenterSource;
id: string;
rank: number;
title: string;
keywords: readonly string[];
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
action: KeyboardActionDefinition;
visibility: "always" | "query";
}): CommandCenterContribution {
return {
id: input.id,
group: "workspace",
groupRank: -1,
rank: input.rank,
keywords: input.keywords,
visibility: input.visibility,
run: () => input.source.dispatch(input.action),
presentation: {
kind: "action",
title: input.title,
sectionTitle: input.source.labels.section,
icon: input.icon,
shortcutKeys: input.shortcutKeys,
},
};
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
buildWorkspaceAction({
source,
id: "tab:new-agent",
rank: 0,
title: source.labels.newAgent,
keywords: ["tab", "new", "agent", "chat"],
icon: source.icons.newAgent,
shortcutKeys: source.shortcuts.newAgent,
action: { id: "workspace.tab.new", scope: "workspace" },
visibility: "always",
}),
];
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
keywords: ["terminal", "shell", "console"],
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
}),
);
}
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
}
return contributions;
}
@@ -0,0 +1,111 @@
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
import type { CommandCenterIcon } from "./contributions";
import { useCommandCenterActions } from "./provider";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
const WORKSPACE_COMMAND_CENTER_ICONS = {
newAgent: getCommandCenterIcon(SquarePen),
newTerminal: getCommandCenterIcon(SquareTerminal),
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2),
};
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined;
function StaticIcon() {
return element;
}
return StaticIcon;
}
function resolveWorkspaceShortcuts(
overrides: Readonly<Record<string, string>>,
): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
return {
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
newTerminal:
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
splitRight:
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
splitDown:
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
};
}
export function useWorkspaceCommandCenterActions(): void {
const { t } = useTranslation();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
});
const runGitAction = useGitActionRunner();
const actions = useMemo(
() =>
buildWorkspaceCommandCenterContributions({
gitActions,
labels: {
section: t("workspace.header.actions.workspaceActions"),
newAgent: t("workspace.tabs.actions.newAgent"),
newTerminal: t("workspace.tabs.actions.newTerminal"),
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
git: (action) => staticIcon(action.icon),
},
shortcuts: resolveWorkspaceShortcuts(overrides),
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
},
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, overrides, runGitAction, t],
);
useCommandCenterActions({
sourceId: "workspace",
enabled: Boolean(serverId && cwd),
actions,
});
}
export function CommandCenterWorkspaceActions() {
useWorkspaceCommandCenterActions();
return null;
}
+31
View File
@@ -0,0 +1,31 @@
import { withUnistyles } from "react-native-unistyles";
import {
Archive,
ArrowDownUp,
Download,
GitCommitHorizontal,
GitMerge,
RefreshCcw,
Upload,
} from "lucide-react-native";
import type { Theme } from "@/styles/theme";
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export const GIT_ACTION_ICONS = {
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
+7 -21
View File
@@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native"; import { ChevronDown, MoreVertical } from "lucide-react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
DropdownMenu, DropdownMenu,
@@ -15,8 +15,8 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/git/policy"; import type { GitAction, GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
gitActions: GitActions; gitActions: GitActions;
@@ -76,7 +76,7 @@ function GitActionMenuItem({
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) { export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const runGitAction = useGitActionRunner();
const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const getActionDisplayLabel = useCallback((action: GitAction): string => { const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -85,26 +85,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
return action.label; return action.label;
}, []); }, []);
const handleActionSelect = useCallback(
(action: GitAction) => {
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <Info size={16} color={theme.colors.foreground} />,
});
return;
}
action.handler();
},
[theme.colors.foreground, toast],
);
const handlePrimaryPress = useCallback(() => { const handlePrimaryPress = useCallback(() => {
if (!gitActions.primary) { if (!gitActions.primary) {
return; return;
} }
handleActionSelect(gitActions.primary); runGitAction(gitActions.primary);
}, [gitActions.primary, handleActionSelect]); }, [gitActions.primary, runGitAction]);
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []); const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
@@ -172,7 +158,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<GitActionMenuItem <GitActionMenuItem
key={action.id} key={action.id}
action={action} action={action}
onSelect={handleActionSelect} onSelect={runGitAction}
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={action.startsGroup} needsSeparator={action.startsGroup}
showSeparator={index > 0} showSeparator={index > 0}
@@ -205,7 +191,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<GitActionMenuItem <GitActionMenuItem
key={action.id} key={action.id}
action={action} action={action}
onSelect={handleActionSelect} onSelect={runGitAction}
closeOnSelect={false} closeOnSelect={false}
/> />
))} ))}
+2 -27
View File
@@ -30,22 +30,15 @@ import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { import {
AlignJustify, AlignJustify,
Archive,
ArrowDownUp,
ChevronDown, ChevronDown,
Columns2, Columns2,
Download,
FolderTree, FolderTree,
GitCommitHorizontal,
GitMerge,
List, List,
ListChevronsDownUp, ListChevronsDownUp,
ListChevronsUpDown, ListChevronsUpDown,
Maximize2, Maximize2,
Pilcrow, Pilcrow,
RefreshCcw,
RotateCw, RotateCw,
Upload,
WrapText, WrapText,
} from "lucide-react-native"; } from "lucide-react-native";
import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query";
@@ -90,6 +83,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { GitActionsSplitButton } from "@/git/actions-split-button"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { BranchSwitcher } from "@/components/branch-switcher"; import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions"; import { useGitActions } from "@/git/use-actions";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge"; import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import type { ForgeAuthState } from "@getpaseo/protocol/messages";
@@ -1330,13 +1324,6 @@ const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedFolderTree = withUnistyles(FolderTree); const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedList = withUnistyles(List); const ThemedList = withUnistyles(List);
const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedMaximize2 = withUnistyles(Maximize2);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronDown = withUnistyles(ChevronDown);
const DIFF_OPTIONS_WHITESPACE_ICON = ( const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} /> <ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
@@ -2823,22 +2810,10 @@ export function GitDiffPane({
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")), () => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t], [baseRef, t],
); );
const gitActionsIcons = useMemo(
() => ({
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
}),
[],
);
const { gitActions, branchLabel } = useGitActions({ const { gitActions, branchLabel } = useGitActions({
serverId, serverId,
cwd, cwd,
icons: gitActionsIcons, icons: GIT_ACTION_ICONS,
}); });
const committedDiffDescription = useMemo( const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel), () => computeCommittedDiffDescription(branchLabel, baseRefLabel),
+22
View File
@@ -1,4 +1,6 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { Info } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -31,6 +33,26 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy"; export type { GitActionId, GitAction, GitActions } from "@/git/policy";
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedInfo = withUnistyles(Info, (theme) => ({ color: theme.colors.foreground }));
export function useGitActionRunner(): (action: GitAction) => void {
const toast = useToast();
return useCallback(
(action: GitAction) => {
if (action.disabled) return;
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <ThemedInfo size={16} />,
});
return;
}
action.handler();
},
[toast],
);
}
/** /**
* The leading icon for every change-request action (create/view/merge) is the * The leading icon for every change-request action (create/view/merge) is the
+2 -34
View File
@@ -1,16 +1,6 @@
import { withUnistyles } from "react-native-unistyles";
import {
Archive,
ArrowDownUp,
Download,
GitCommitHorizontal,
GitMerge,
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitActionsSplitButton } from "@/git/actions-split-button"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions"; import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme"; import { GIT_ACTION_ICONS } from "@/git/action-icons";
interface WorkspaceActionsProps { interface WorkspaceActionsProps {
serverId: string; serverId: string;
@@ -18,33 +8,11 @@ interface WorkspaceActionsProps {
hideLabels?: boolean; hideLabels?: boolean;
} }
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ICONS = {
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({ const { gitActions } = useGitActions({
serverId, serverId,
cwd, cwd,
icons: ICONS, icons: GIT_ACTION_ICONS,
}); });
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />; return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
+5 -13
View File
@@ -1,7 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
@@ -12,16 +11,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const isDesktopApp = getIsElectronRuntime(); const isDesktopApp = getIsElectronRuntime();
return useMemo(() => { return useMemo(() => {
const platform = { isMac, isDesktop: isDesktopApp }; return resolveShortcutKeysForAction(actionId, overrides, {
const bindingId = getBindingIdForAction(actionId, platform); isMac,
if (!bindingId) return null; isDesktop: isDesktopApp,
});
const override = overrides[bindingId];
if (override) {
return chordStringToShortcutKeys(override);
}
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktopApp]); }, [actionId, overrides, isMac, isDesktopApp]);
} }
@@ -27,6 +27,7 @@ export type KeyboardActionId =
| "workspace.pane.close" | "workspace.pane.close"
| "workspace.focus.toggle" | "workspace.focus.toggle"
| "workspace.terminal.new" | "workspace.terminal.new"
| "workspace.browser.new"
| "sidebar.toggle.right" | "sidebar.toggle.right"
| "workspace.new" | "workspace.new"
| "workspace.project.pick" | "workspace.project.pick"
@@ -61,6 +62,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; 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: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "workspace.project.pick"; scope: KeyboardActionScope }
@@ -6,6 +6,7 @@ import type {
MessageInputKeyboardActionKind, MessageInputKeyboardActionKind,
} from "@/keyboard/actions"; } from "@/keyboard/actions";
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string"; import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string"; export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -1406,6 +1407,19 @@ export function getDefaultKeysForAction(
return null; return null;
} }
export function resolveShortcutKeysForAction(
actionId: string,
overrides: Readonly<Record<string, string>>,
platform: { isMac: boolean; isDesktop: boolean },
): ShortcutKey[][] | null {
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return null;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}
/** /**
* The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number * The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number
* badges. It must match the modifier of the active `workspace.navigate.index` * badges. It must match the modifier of the active `workspace.navigate.index`
@@ -3048,6 +3048,9 @@ function WorkspaceScreenContent({
case "workspace.terminal.new": case "workspace.terminal.new":
handleCreateTerminal(); handleCreateTerminal();
return true; return true;
case "workspace.browser.new":
handleCreateBrowserTab();
return true;
case "workspace.tab.close-current": case "workspace.tab.close-current":
if (activeTabId) { if (activeTabId) {
void handleCloseTabById(activeTabId); void handleCloseTabById(activeTabId);
@@ -3080,6 +3083,7 @@ function WorkspaceScreenContent({
activeTabId, activeTabId,
handleCloseTabById, handleCloseTabById,
handleCreateDraftTab, handleCreateDraftTab,
handleCreateBrowserTab,
handleCreateTerminal, handleCreateTerminal,
navigateToTabId, navigateToTabId,
tabs, tabs,
@@ -3194,6 +3198,7 @@ function WorkspaceScreenContent({
"workspace.tab.navigate-index", "workspace.tab.navigate-index",
"workspace.tab.navigate-relative", "workspace.tab.navigate-relative",
"workspace.terminal.new", "workspace.terminal.new",
"workspace.browser.new",
] as const, ] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100, priority: 100,