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
@@ -5,13 +5,15 @@ import {
Text,
TextInput,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { router, type Href } from "expo-router";
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 {
BottomSheetBackdrop,
@@ -25,11 +27,9 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} 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 { 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 {
OverlayLayerProvider,
@@ -39,22 +39,16 @@ import {
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-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 {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
import { useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
joinSubtitleParts,
@@ -79,113 +73,9 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
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 KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
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;
}
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
@@ -205,6 +95,10 @@ function matchesQuery(searchText: string, query: string): boolean {
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[] {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
@@ -249,36 +143,40 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const workspaces = allWorkspaces.filter((workspace) =>
matchesQuery(workspace.searchText, query),
const workspaces = limitDefaultCategoryResults(
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
query,
);
const agentResults = limitDefaultCategoryResults(
agents
.map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
showHost ? agent.serverLabel : null,
location,
formatTimeAgo(agent.lastActivityAt),
]);
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title,
subtitle,
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent)),
query,
);
const agentResults = agents
.map<CommandCenterAgentResult>((agent) => {
const title = agent.title || t("shell.commandCenter.newAgent");
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const location = workspaceTitle ?? shortenPath(agent.cwd);
const subtitle = joinSubtitleParts([
showHost ? agent.serverLabel : null,
location,
formatTimeAgo(agent.lastActivityAt),
]);
return {
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title,
subtitle,
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
run: () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
},
};
})
.filter((agent) => matchesQuery(agent.searchText, query))
.sort((left, right) => sortAgents(left.agent, right.agent));
return [
{
id: "workspaces",
@@ -570,19 +468,54 @@ export function CommandCenter() {
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
onClose: state.close,
});
useEffect(() => {
const revealActiveResult = useCallback(() => {
if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId);
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;
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
const boundedOffset = Math.max(0, nextOffset);
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(() => {
if (!showBottomSheet || !state.open) return;
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
@@ -626,6 +559,9 @@ export function CommandCenter() {
initialNumToRender: 12,
maxToRenderPerBatch: 10,
windowSize: 5,
onLayout: handleListLayout,
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
@@ -786,10 +722,10 @@ const styles = StyleSheet.create((theme) => ({
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
lineHeight: 18,
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" },
rowShortcut: { flexShrink: 0 },
breadcrumb: {
@@ -72,6 +72,16 @@ describe("Command Center result projection", () => {
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", () => {
const first = workspace("workspace:first");
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;
}