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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user