diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 16aa4b0f6..2f7fcd2fd 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -21,7 +21,8 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
-import { CommandCenter } from "@/components/command-center";
+import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center";
+import { CommandCenterProvider } from "@/command-center/provider";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
@@ -553,6 +554,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
+
@@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
- return content;
+ return {content};
}
function SidebarChrome({
diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx
new file mode 100644
index 000000000..584c80bfc
--- /dev/null
+++ b/packages/app/src/command-center/command-center.tsx
@@ -0,0 +1,792 @@
+import {
+ FlatList,
+ Modal,
+ Pressable,
+ Text,
+ TextInput,
+ View,
+ type ListRenderItemInfo,
+ 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 { StyleSheet, withUnistyles } from "react-native-unistyles";
+import {
+ BottomSheetBackdrop,
+ BottomSheetFlatList,
+ BottomSheetTextInput,
+ type BottomSheetFlatListMethods,
+} from "@gorhom/bottom-sheet";
+import { AgentStatusDot } from "@/components/agent-status-dot";
+import { Shortcut } from "@/components/ui/shortcut";
+import {
+ IsolatedBottomSheetModal,
+ useIsolatedBottomSheetVisibility,
+} from "@/components/ui/isolated-bottom-sheet-modal";
+import { getIsElectronRuntime, 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 { 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 {
+ buildContributionSections,
+ moveActiveResultId,
+ preserveActiveResultId,
+ projectCommandCenterRows,
+ type CommandCenterAgentResult,
+ type CommandCenterListRow,
+ type CommandCenterResult,
+ type CommandCenterResultSection,
+ type CommandCenterWorkspaceResult,
+} from "./results";
+
+const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
+ placeholderTextColor: theme.colors.foregroundMuted,
+}));
+const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
+ placeholderTextColor: theme.colors.foregroundMuted,
+}));
+const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
+const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
+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 ;
+}
+
+function SettingsIcon({ size }: CommandCenterIconProps) {
+ return ;
+}
+
+function HomeIcon({ size }: CommandCenterIconProps) {
+ return ;
+}
+
+function resolveActionShortcutKeys(
+ actionId: string | undefined,
+ overrides: Record,
+): 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(() => buildSettingsRoute(), []);
+ const homeRoute = useMemo(() => buildOpenProjectRoute(), []);
+ const actions = useMemo(
+ () => [
+ {
+ 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 {
+ const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
+ const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
+ if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
+ const leftAttention = left.requiresAttention ? 1 : 0;
+ const rightAttention = right.requiresAttention ? 1 : 0;
+ if (leftAttention !== rightAttention) return rightAttention - leftAttention;
+ const leftRunning = left.status === "running" ? 1 : 0;
+ const rightRunning = right.status === "running" ? 1 : 0;
+ if (leftRunning !== rightRunning) return rightRunning - leftRunning;
+ return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
+}
+
+function matchesQuery(searchText: string, query: string): boolean {
+ const normalized = query.trim().toLowerCase();
+ return !normalized || searchText.includes(normalized);
+}
+
+function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
+ const { t } = useTranslation();
+ const { agents } = useAggregatedAgents();
+ const { projects } = useProjects({ enabled: open });
+ const showAgentHost = useHosts().length > 1;
+
+ return useMemo(() => {
+ if (!open) return [];
+ const allWorkspaces: CommandCenterWorkspaceResult[] = [];
+ for (const project of projects) {
+ for (const host of project.hosts) {
+ for (const workspace of host.workspaces) {
+ if (workspace.archivingAt) continue;
+ const title = workspace.title ?? workspace.name;
+ const subtitle = workspace.currentBranch
+ ? `${host.serverName} · ${workspace.currentBranch}`
+ : host.serverName;
+ const searchText = `${title} ${subtitle}`.toLowerCase();
+ allWorkspaces.push({
+ kind: "workspace",
+ id: `workspace:${host.serverId}:${workspace.id}`,
+ title,
+ subtitle,
+ searchText,
+ run: () => {
+ clearCommandCenterFocusRestoreElement();
+ navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
+ },
+ });
+ }
+ }
+ }
+ allWorkspaces.sort((left, right) => {
+ const titleDelta = left.title.localeCompare(right.title, undefined, {
+ numeric: true,
+ sensitivity: "base",
+ });
+ return titleDelta || left.subtitle.localeCompare(right.subtitle);
+ });
+ const workspaceTitleByKey = new Map(
+ allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
+ );
+ const workspaces = allWorkspaces.filter((workspace) =>
+ matchesQuery(workspace.searchText, query),
+ );
+ const agentResults = agents
+ .map((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 = [
+ showAgentHost ? agent.serverLabel : null,
+ location,
+ formatTimeAgo(agent.lastActivityAt),
+ ]
+ .filter((part): part is string => Boolean(part))
+ .join(" · ");
+ 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",
+ rank: 2,
+ title: t("shell.commandCenter.workspaces"),
+ results: workspaces,
+ },
+ { id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
+ ];
+ }, [agents, open, projects, query, showAgentHost, t]);
+}
+
+interface CommandCenterState {
+ open: boolean;
+ query: string;
+ setQuery(query: string): void;
+ activeId: string | null;
+ rows: readonly CommandCenterListRow[];
+ results: readonly CommandCenterResult[];
+ rowIndexByResultId: ReadonlyMap;
+ offsets: readonly number[];
+ inputRef: React.RefObject;
+ close(): void;
+ select(result: CommandCenterResult): void;
+ key(key: string): boolean;
+}
+
+function useCommandCenterState(): CommandCenterState {
+ const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
+ const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
+ const snapshot = useCommandCenterContributions();
+ const inputRef = useRef(null);
+ const previousOpenRef = useRef(open);
+ const [query, setQuery] = useState("");
+ const [activeId, setActiveId] = useState(null);
+ const builtInSections = useBuiltInSections(open, query);
+ const contributionSections = useMemo(
+ () => buildContributionSections(snapshot.contributions, query),
+ [query, snapshot.contributions],
+ );
+ const projection = useMemo(
+ () => projectCommandCenterRows([...contributionSections, ...builtInSections]),
+ [builtInSections, contributionSections],
+ );
+ const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
+
+ const close = useCallback(() => setOpen(false), [setOpen]);
+ const select = useCallback(
+ (result: CommandCenterResult) => {
+ setOpen(false);
+ void result.run();
+ },
+ [setOpen],
+ );
+ const key = useCallback(
+ (pressed: string): boolean => {
+ if (!open) return false;
+ const results = projection.selectableResults;
+ if (pressed === "Escape") {
+ close();
+ return true;
+ }
+ if (pressed === "Enter") {
+ const selected = results.find((result) => result.id === resolvedActiveId);
+ if (!selected) return false;
+ select(selected);
+ return true;
+ }
+ if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
+ if (results.length === 0) return false;
+ const direction = pressed === "ArrowDown" ? "next" : "previous";
+ setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
+ return true;
+ },
+ [close, open, projection.selectableResults, resolvedActiveId, select],
+ );
+
+ useEffect(() => {
+ const wasOpen = previousOpenRef.current;
+ previousOpenRef.current = open;
+ if (open) {
+ const timer = setTimeout(() => inputRef.current?.focus(), 0);
+ return () => clearTimeout(timer);
+ }
+ setQuery("");
+ setActiveId(null);
+ if (!wasOpen) return;
+ const element = takeCommandCenterFocusRestoreElement();
+ if (!element) return;
+ const cancel = focusWithRetries({
+ focus: () => element.focus(),
+ isFocused: () => typeof document !== "undefined" && document.activeElement === element,
+ onTimeout: () =>
+ keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }),
+ });
+ return cancel;
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !isWeb) return;
+ const listener = (event: KeyboardEvent) => {
+ if (key(event.key)) event.preventDefault();
+ };
+ window.addEventListener("keydown", listener, true);
+ return () => window.removeEventListener("keydown", listener, true);
+ }, [key, open]);
+
+ return {
+ open,
+ query,
+ setQuery,
+ activeId: resolvedActiveId,
+ rows: projection.rows,
+ results: projection.selectableResults,
+ rowIndexByResultId: projection.rowIndexByResultId,
+ offsets: projection.offsets,
+ inputRef,
+ close,
+ select,
+ key,
+ };
+}
+
+interface ResultRowProps {
+ result: CommandCenterResult;
+ active: boolean;
+ onSelect(result: CommandCenterResult): void;
+}
+
+const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
+ const press = useCallback(() => onSelect(result), [onSelect, result]);
+ const style = useCallback(
+ ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
+ styles.row,
+ (result.kind === "agent" ||
+ result.kind === "workspace" ||
+ (result.kind === "contribution" &&
+ result.contribution.presentation.kind === "action" &&
+ Boolean(result.contribution.presentation.subtitle))) &&
+ styles.tallRow,
+ (Boolean(hovered) || pressed || active) && styles.activeRow,
+ ],
+ [active, result],
+ );
+ return (
+
+
+
+ );
+});
+
+function ResultContent({ result }: { result: CommandCenterResult }) {
+ if (result.kind === "agent") {
+ const agent = result.agent;
+ return (
+
+
+
+
+
+
+
+ {result.title}
+
+
+ {result.subtitle}
+
+
+
+
+ );
+ }
+ if (result.kind === "workspace") {
+ const key = result.id.slice("workspace:".length);
+ return (
+
+
+
+
+
+
+
+ {result.title}
+
+
+ {result.subtitle}
+
+
+
+
+ );
+ }
+ const presentation = result.contribution.presentation;
+ const Icon = presentation.icon;
+ if (presentation.kind === "action") {
+ return (
+
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+
+ {presentation.title}
+
+ {presentation.subtitle ? (
+ {presentation.subtitle}
+ ) : null}
+
+
+ {presentation.shortcutKeys ? (
+
+ ) : null}
+
+ );
+ }
+ return (
+
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+ {presentation.path.map((part, index) => (
+
+ {index > 0 ? : null}
+
+ {part}
+
+
+ ))}
+
+
+ {presentation.selected ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+function SectionRow({ row }: { row: Extract }) {
+ let sizeStyle = styles.dividerSection;
+ if (row.title && row.divider) sizeStyle = styles.dividedSection;
+ if (row.title && !row.divider) sizeStyle = styles.titledSection;
+ return (
+
+ {row.divider ? : null}
+ {row.title ? {row.title} : null}
+
+ );
+}
+
+export function CommandCenter() {
+ const { t } = useTranslation();
+ const state = useCommandCenterState();
+ const isCompact = useIsCompactFormFactor();
+ const showBottomSheet = isCompact && isNative;
+ const listRef = useRef>(null);
+ const bottomSheetListRef = useRef(null);
+ const bottomSheetInputRef = useRef>(null);
+ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
+ visible: state.open,
+ isEnabled: showBottomSheet,
+ onClose: state.close,
+ });
+
+ useEffect(() => {
+ if (!state.open || !state.activeId) return;
+ const index = state.rowIndexByResultId.get(state.activeId);
+ if (index === undefined) return;
+ const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
+ ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
+ }, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
+ useEffect(() => {
+ if (!showBottomSheet || !state.open) return;
+ const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
+ return () => clearTimeout(timer);
+ }, [showBottomSheet, state.open]);
+
+ const renderItem = useCallback(
+ ({ item }: ListRenderItemInfo) =>
+ item.kind === "section" ? (
+
+ ) : (
+
+ ),
+ [state.activeId, state.select],
+ );
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ index,
+ length: state.rows[index].height,
+ offset: state.offsets[index],
+ }),
+ [state.offsets, state.rows],
+ );
+ const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
+ const empty = useMemo(
+ () => {t("shell.commandCenter.noMatches")},
+ [t],
+ );
+ const commonListProps = {
+ data: state.rows,
+ renderItem,
+ keyExtractor,
+ getItemLayout,
+ ListEmptyComponent: empty,
+ keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
+ showsVerticalScrollIndicator: false,
+ initialNumToRender: 12,
+ maxToRenderPerBatch: 10,
+ windowSize: 5,
+ };
+ const keyPress = useCallback(
+ ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
+ [state],
+ );
+ const submit = useCallback(() => state.key("Enter"), [state]);
+ const backdrop = useCallback(
+ (props: React.ComponentProps) => (
+
+ ),
+ [],
+ );
+
+ if (showBottomSheet) {
+ return (
+
+
+
+
+
+
+ );
+ }
+ if (!state.open) return null;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ overlay: {
+ flex: 1,
+ justifyContent: "flex-start",
+ alignItems: "center",
+ paddingTop: theme.spacing[12],
+ },
+ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
+ panel: {
+ width: 640,
+ maxWidth: "92%",
+ maxHeight: "80%",
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ borderRadius: theme.borderRadius.lg,
+ overflow: "hidden",
+ backgroundColor: theme.colors.surface0,
+ ...theme.shadow.lg,
+ },
+ header: {
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ bottomSheetHeader: {
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ input: {
+ fontSize: theme.fontSize.base,
+ paddingVertical: theme.spacing[1],
+ color: theme.colors.foreground,
+ outlineWidth: 0,
+ },
+ results: { flexGrow: 0 },
+ sectionLabel: {
+ paddingHorizontal: theme.spacing[4],
+ paddingBottom: theme.spacing[2],
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
+ },
+ sectionDivider: {
+ height: 1,
+ marginTop: theme.spacing[2],
+ marginBottom: theme.spacing[2],
+ backgroundColor: theme.colors.border,
+ },
+ row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
+ tallRow: { height: 56 },
+ activeRow: { backgroundColor: theme.colors.surface1 },
+ rowContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.spacing[3],
+ },
+ rowMain: {
+ flex: 1,
+ minWidth: 0,
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: theme.spacing[3],
+ },
+ textContent: { flex: 1, minWidth: 0 },
+ title: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ lineHeight: 20,
+ flexShrink: 1,
+ },
+ subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 },
+ iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
+ rowShortcut: { flexShrink: 0 },
+ breadcrumb: {
+ flex: 1,
+ minWidth: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ },
+ breadcrumbPart: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ flexShrink: 1,
+ },
+ breadcrumbGroup: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ lineHeight: 20,
+ flexShrink: 0,
+ },
+ emptyText: {
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[6],
+ textAlign: "center",
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ sheetBackground: { backgroundColor: theme.colors.surface0 },
+ sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
+ titledSection: { height: 32, justifyContent: "flex-end" },
+ dividedSection: { height: 49, justifyContent: "flex-end" },
+ dividerSection: { height: 17, justifyContent: "flex-end" },
+}));
diff --git a/packages/app/src/command-center/contributions.ts b/packages/app/src/command-center/contributions.ts
new file mode 100644
index 000000000..ab54cbc7e
--- /dev/null
+++ b/packages/app/src/command-center/contributions.ts
@@ -0,0 +1,53 @@
+import type { ComponentType } from "react";
+import type { ShortcutKey } from "@/utils/format-shortcut";
+
+export interface CommandCenterIconProps {
+ size: number;
+}
+
+export type CommandCenterIcon = ComponentType;
+
+interface CommandCenterContributionBase {
+ id: string;
+ group: string;
+ groupRank: number;
+ rank: number;
+ keywords: readonly string[];
+ visibility: "always" | "query";
+ run(): void | Promise;
+}
+
+export type CommandCenterContribution =
+ | (CommandCenterContributionBase & {
+ presentation: {
+ kind: "action";
+ title: string;
+ subtitle?: string;
+ sectionTitle?: string;
+ icon?: CommandCenterIcon;
+ shortcutKeys?: ShortcutKey[][];
+ };
+ })
+ | (CommandCenterContributionBase & {
+ presentation: {
+ kind: "choice";
+ path: readonly [string, ...string[]];
+ icon?: CommandCenterIcon;
+ selected: boolean;
+ testId?: string;
+ };
+ });
+
+export interface CommandCenterContributionSnapshot {
+ contributions: readonly CommandCenterContribution[];
+}
+
+export interface CommandCenterRegistrationOwner {
+ sourceId: string;
+ token: symbol;
+}
+
+export interface CommandCenterRegistration {
+ owner: CommandCenterRegistrationOwner;
+ contributions: readonly CommandCenterContribution[];
+}
diff --git a/packages/app/src/command-center/model-contributions.test.tsx b/packages/app/src/command-center/model-contributions.test.tsx
new file mode 100644
index 000000000..6e38b33e1
--- /dev/null
+++ b/packages/app/src/command-center/model-contributions.test.tsx
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
+import { buildModelChoiceContributions } from "./model-contributions";
+
+function TestIcon() {
+ return null;
+}
+
+function provider(input: {
+ id: string;
+ label: string;
+ state: "models" | "error";
+}): ProviderSelectorProvider {
+ if (input.state === "error") {
+ return {
+ id: input.id,
+ label: input.label,
+ modelSelection: { kind: "error", message: "Unavailable" },
+ };
+ }
+ return {
+ id: input.id,
+ label: input.label,
+ modelSelection: {
+ kind: "models",
+ rows: [
+ {
+ favoriteKey: `${input.id}:model`,
+ provider: input.id,
+ providerLabel: input.label,
+ modelId: "model",
+ modelLabel: `${input.label} model`,
+ description: undefined,
+ },
+ ],
+ },
+ };
+}
+
+describe("Command Center model choices", () => {
+ it("publishes selectable providers and executes the draft owner's command", () => {
+ const selections: string[] = [];
+ const choices = buildModelChoiceContributions({
+ serverId: "host",
+ providers: [
+ provider({ id: "claude", label: "Claude", state: "models" }),
+ provider({ id: "codex", label: "Codex", state: "models" }),
+ ],
+ selectedProvider: "codex",
+ selectedModelId: "model",
+ groupLabel: "Model",
+ searchKeywords: "model switch",
+ getIcon: () => TestIcon,
+ select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`),
+ });
+
+ expect(
+ choices.map((choice) => ({
+ id: choice.id,
+ selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false,
+ })),
+ ).toEqual([
+ { id: "host:claude:model", selected: false },
+ { id: "host:codex:model", selected: true },
+ ]);
+ choices[0].run();
+ choices[1].run();
+ expect(selections).toEqual(["claude:model"]);
+ });
+
+ it("does not publish unavailable providers or no-op default rows", () => {
+ const choices = buildModelChoiceContributions({
+ serverId: "host",
+ providers: [
+ provider({ id: "unavailable", label: "Unavailable", state: "error" }),
+ {
+ id: "empty",
+ label: "Empty",
+ modelSelection: {
+ kind: "models",
+ rows: [
+ {
+ favoriteKey: "empty:",
+ provider: "empty",
+ providerLabel: "Empty",
+ modelId: "",
+ modelLabel: "Default",
+ description: undefined,
+ },
+ ],
+ },
+ },
+ ],
+ selectedProvider: null,
+ selectedModelId: null,
+ groupLabel: "Model",
+ searchKeywords: "model switch",
+ getIcon: () => TestIcon,
+ select: () => undefined,
+ });
+
+ expect(choices).toEqual([]);
+ });
+});
diff --git a/packages/app/src/command-center/model-contributions.ts b/packages/app/src/command-center/model-contributions.ts
new file mode 100644
index 000000000..182a528c9
--- /dev/null
+++ b/packages/app/src/command-center/model-contributions.ts
@@ -0,0 +1,51 @@
+import type { AgentProvider } from "@getpaseo/protocol/agent-types";
+import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
+import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
+
+interface ModelChoiceInput {
+ serverId: string;
+ providers: readonly ProviderSelectorProvider[];
+ selectedProvider: string | null;
+ selectedModelId: string | null;
+ groupLabel: string;
+ searchKeywords: string;
+ getIcon(provider: AgentProvider): CommandCenterIcon;
+ select(provider: AgentProvider, modelId: string): void;
+}
+
+export function buildModelChoiceContributions(
+ input: ModelChoiceInput,
+): CommandCenterContribution[] {
+ const contributions: CommandCenterContribution[] = [];
+ let rank = 0;
+ for (const provider of input.providers) {
+ if (provider.modelSelection.kind !== "models") continue;
+ const agentProvider = provider.id;
+ const icon = input.getIcon(agentProvider);
+ for (const model of provider.modelSelection.rows) {
+ if (!model.modelId) continue;
+ const modelId = model.modelId;
+ const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId;
+ contributions.push({
+ id: `${input.serverId}:${provider.id}:${modelId}`,
+ group: "models",
+ groupRank: 1,
+ rank,
+ keywords: [modelId, input.searchKeywords],
+ visibility: "query",
+ run: () => {
+ if (!selected) input.select(agentProvider, modelId);
+ },
+ presentation: {
+ kind: "choice",
+ path: [input.groupLabel, provider.label, model.modelLabel],
+ icon,
+ selected,
+ testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`,
+ },
+ });
+ rank += 1;
+ }
+ }
+ return contributions;
+}
diff --git a/packages/app/src/command-center/provider-icon.tsx b/packages/app/src/command-center/provider-icon.tsx
new file mode 100644
index 000000000..245d5de27
--- /dev/null
+++ b/packages/app/src/command-center/provider-icon.tsx
@@ -0,0 +1,20 @@
+import { withUnistyles } from "react-native-unistyles";
+import type { AgentProvider } from "@getpaseo/protocol/agent-types";
+import { getProviderIcon } from "@/components/provider-icons";
+import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
+
+const commandCenterProviderIcons = new Map();
+
+export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon {
+ const cached = commandCenterProviderIcons.get(provider);
+ if (cached) return cached;
+
+ const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({
+ color: theme.colors.foregroundMuted,
+ }));
+ function CommandCenterProviderIcon({ size }: CommandCenterIconProps) {
+ return ;
+ }
+ commandCenterProviderIcons.set(provider, CommandCenterProviderIcon);
+ return CommandCenterProviderIcon;
+}
diff --git a/packages/app/src/command-center/provider.tsx b/packages/app/src/command-center/provider.tsx
new file mode 100644
index 000000000..2f1cc706c
--- /dev/null
+++ b/packages/app/src/command-center/provider.tsx
@@ -0,0 +1,56 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useRef,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
+import type { CommandCenterContribution } from "./contributions";
+import { createCommandCenterRegistry, type CommandCenterRegistry } from "./registry";
+
+const CommandCenterRegistryContext = createContext(null);
+
+export function CommandCenterProvider({ children }: { children: ReactNode }) {
+ const registryRef = useRef(null);
+ if (!registryRef.current) registryRef.current = createCommandCenterRegistry();
+
+ return (
+
+ {children}
+
+ );
+}
+
+function useCommandCenterRegistry(): CommandCenterRegistry {
+ const registry = useContext(CommandCenterRegistryContext);
+ if (!registry) throw new Error("CommandCenterProvider is required");
+ return registry;
+}
+
+export function useCommandCenterContributions() {
+ const registry = useCommandCenterRegistry();
+ return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
+}
+
+export function useCommandCenterActions(input: {
+ sourceId: string;
+ enabled: boolean;
+ actions: readonly CommandCenterContribution[];
+}): void {
+ const registry = useCommandCenterRegistry();
+ const ownerRef = useRef({ sourceId: input.sourceId, token: Symbol(input.sourceId) });
+ if (ownerRef.current.sourceId !== input.sourceId) {
+ ownerRef.current = { sourceId: input.sourceId, token: Symbol(input.sourceId) };
+ }
+ const owner = ownerRef.current;
+
+ useEffect(() => {
+ if (!input.enabled) {
+ registry.remove(owner);
+ return;
+ }
+ registry.replace({ owner, contributions: input.actions });
+ return () => registry.remove(owner);
+ }, [input.actions, input.enabled, owner, registry]);
+}
diff --git a/packages/app/src/command-center/registry.test.ts b/packages/app/src/command-center/registry.test.ts
new file mode 100644
index 000000000..cf8d60684
--- /dev/null
+++ b/packages/app/src/command-center/registry.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import type { CommandCenterContribution, CommandCenterRegistrationOwner } from "./contributions";
+import { createCommandCenterRegistry } from "./registry";
+
+function action(id: string, rank: number): CommandCenterContribution {
+ return {
+ id,
+ group: "actions",
+ groupRank: 0,
+ rank,
+ keywords: [],
+ visibility: "always",
+ run: () => undefined,
+ presentation: { kind: "action", title: id },
+ };
+}
+
+function owner(sourceId: string): CommandCenterRegistrationOwner {
+ return { sourceId, token: Symbol(sourceId) };
+}
+
+describe("Command Center registry", () => {
+ it("atomically replaces a source and preserves a no-op snapshot", () => {
+ const registry = createCommandCenterRegistry();
+ const source = owner("root");
+ const first = [action("first", 0)];
+ let notifications = 0;
+ registry.subscribe(() => {
+ notifications += 1;
+ });
+
+ registry.replace({ owner: source, contributions: first });
+ const snapshot = registry.getSnapshot();
+ registry.replace({ owner: source, contributions: first });
+ expect(registry.getSnapshot()).toBe(snapshot);
+ expect(notifications).toBe(1);
+
+ registry.replace({ owner: source, contributions: [action("second", 0)] });
+ expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["root:second"]);
+ expect(notifications).toBe(2);
+ });
+
+ it("does not let stale cleanup remove a replacement owner", () => {
+ const registry = createCommandCenterRegistry();
+ const stale = owner("draft:tab");
+ const current = owner("draft:tab");
+ registry.replace({ owner: stale, contributions: [action("old", 0)] });
+ registry.replace({ owner: current, contributions: [action("new", 0)] });
+
+ registry.remove(stale);
+ expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["draft:tab:new"]);
+ registry.remove(current);
+ expect(registry.getSnapshot().contributions).toEqual([]);
+ });
+
+ it("orders independently of registration order and rejects duplicate active ids", () => {
+ const registry = createCommandCenterRegistry();
+ registry.replace({ owner: owner("later"), contributions: [action("z", 2)] });
+ registry.replace({ owner: owner("earlier"), contributions: [action("a", 1)] });
+ expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual([
+ "earlier:a",
+ "later:z",
+ ]);
+
+ const duplicateOwner = owner("duplicate");
+ expect(() =>
+ registry.replace({
+ owner: duplicateOwner,
+ contributions: [action("same", 0), action("same", 1)],
+ }),
+ ).toThrow("Duplicate Command Center contribution id: duplicate:same");
+ });
+});
diff --git a/packages/app/src/command-center/registry.ts b/packages/app/src/command-center/registry.ts
new file mode 100644
index 000000000..f4c383621
--- /dev/null
+++ b/packages/app/src/command-center/registry.ts
@@ -0,0 +1,100 @@
+import type {
+ CommandCenterContribution,
+ CommandCenterContributionSnapshot,
+ CommandCenterRegistration,
+ CommandCenterRegistrationOwner,
+} from "./contributions";
+
+export interface CommandCenterRegistry {
+ getSnapshot(): CommandCenterContributionSnapshot;
+ subscribe(listener: () => void): () => void;
+ replace(registration: CommandCenterRegistration): void;
+ remove(owner: CommandCenterRegistrationOwner): void;
+}
+
+interface ActiveRegistration {
+ owner: CommandCenterRegistrationOwner;
+ contributions: readonly CommandCenterContribution[];
+}
+
+const EMPTY_SNAPSHOT: CommandCenterContributionSnapshot = { contributions: [] };
+
+function contributionId(sourceId: string, id: string): string {
+ return `${sourceId}:${id}`;
+}
+
+function compareContributions(
+ left: CommandCenterContribution,
+ right: CommandCenterContribution,
+): number {
+ if (left.groupRank !== right.groupRank) return left.groupRank - right.groupRank;
+ const groupDelta = left.group.localeCompare(right.group);
+ if (groupDelta !== 0) return groupDelta;
+ if (left.rank !== right.rank) return left.rank - right.rank;
+ return left.id.localeCompare(right.id);
+}
+
+function sameContributions(
+ left: readonly CommandCenterContribution[],
+ right: readonly CommandCenterContribution[],
+): boolean {
+ return left.length === right.length && left.every((item, index) => item === right[index]);
+}
+
+export function createCommandCenterRegistry(): CommandCenterRegistry {
+ const registrations = new Map();
+ const listeners = new Set<() => void>();
+ let snapshot = EMPTY_SNAPSHOT;
+
+ function publish(): void {
+ const contributions: CommandCenterContribution[] = [];
+ const ids = new Set();
+
+ for (const registration of registrations.values()) {
+ for (const contribution of registration.contributions) {
+ const id = contributionId(registration.owner.sourceId, contribution.id);
+ if (ids.has(id)) {
+ throw new Error(`Duplicate Command Center contribution id: ${id}`);
+ }
+ ids.add(id);
+ contributions.push({ ...contribution, id });
+ }
+ }
+ contributions.sort(compareContributions);
+
+ if (sameContributions(snapshot.contributions, contributions)) return;
+ snapshot = contributions.length === 0 ? EMPTY_SNAPSHOT : { contributions };
+ for (const listener of listeners) listener();
+ }
+
+ return {
+ getSnapshot: () => snapshot,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ replace(registration) {
+ const current = registrations.get(registration.owner.sourceId);
+ if (
+ current?.owner.token === registration.owner.token &&
+ sameContributions(current.contributions, registration.contributions)
+ ) {
+ return;
+ }
+ const ids = new Set();
+ for (const contribution of registration.contributions) {
+ const id = contributionId(registration.owner.sourceId, contribution.id);
+ if (ids.has(id)) throw new Error(`Duplicate Command Center contribution id: ${id}`);
+ ids.add(id);
+ }
+ registrations.set(registration.owner.sourceId, registration);
+ publish();
+ },
+ remove(owner) {
+ const current = registrations.get(owner.sourceId);
+ if (current?.owner.token !== owner.token) return;
+ registrations.delete(owner.sourceId);
+ publish();
+ },
+ };
+}
diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts
new file mode 100644
index 000000000..3405a6a51
--- /dev/null
+++ b/packages/app/src/command-center/results.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+import type { CommandCenterContribution } from "./contributions";
+import {
+ buildContributionSections,
+ moveActiveResultId,
+ preserveActiveResultId,
+ projectCommandCenterRows,
+ type CommandCenterWorkspaceResult,
+} from "./results";
+
+function contribution(input: {
+ id: string;
+ group: string;
+ groupRank: number;
+ visibility?: "always" | "query";
+}): CommandCenterContribution {
+ return {
+ ...input,
+ rank: 0,
+ keywords: [input.id],
+ visibility: input.visibility ?? "always",
+ run: () => undefined,
+ presentation: {
+ kind: "action",
+ title: input.id,
+ sectionTitle: input.group,
+ },
+ };
+}
+
+function workspace(id: string): CommandCenterWorkspaceResult {
+ return {
+ kind: "workspace",
+ id,
+ title: id,
+ subtitle: "host",
+ searchText: id,
+ run: () => undefined,
+ };
+}
+
+function sectionResultIds(sections: ReturnType): string[] {
+ const ids: string[] = [];
+ for (const section of sections) {
+ for (const result of section.results) ids.push(result.id);
+ }
+ return ids;
+}
+
+describe("Command Center result projection", () => {
+ it("query-gates model choices and creates one flat row index", () => {
+ const contributions = [
+ contribution({ id: "settings", group: "actions", groupRank: 0 }),
+ contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
+ ];
+ const emptySections = buildContributionSections(contributions, "");
+ expect(sectionResultIds(emptySections)).toEqual(["settings"]);
+
+ const sections = buildContributionSections(contributions, "o");
+ const projection = projectCommandCenterRows([
+ ...sections,
+ { id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
+ ]);
+ expect(projection.rows.map((row) => row.key)).toEqual([
+ "section:models",
+ "opus",
+ "section:workspaces",
+ "workspace:1",
+ ]);
+ expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3);
+ expect(projection.offsets).toEqual([0, 32, 68, 117]);
+ });
+
+ it("preserves active selection by id and falls back to the first result", () => {
+ const first = workspace("workspace:first");
+ const second = workspace("workspace:second");
+ expect(preserveActiveResultId(second.id, [first, second])).toBe(second.id);
+ expect(preserveActiveResultId("missing", [first, second])).toBe(first.id);
+ expect(preserveActiveResultId(first.id, [])).toBeNull();
+ });
+
+ it("wraps keyboard selection in both directions", () => {
+ const first = workspace("workspace:first");
+ const second = workspace("workspace:second");
+ expect(moveActiveResultId(second.id, [first, second], "next")).toBe(first.id);
+ expect(moveActiveResultId(first.id, [first, second], "previous")).toBe(second.id);
+ });
+
+ it("keeps keyboard selection aligned with rows beyond the render window", () => {
+ const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
+ const projection = projectCommandCenterRows([
+ { id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
+ ]);
+
+ let activeId: string | null = workspaces[0].id;
+ for (let index = 0; index < 150; index += 1) {
+ activeId = moveActiveResultId(activeId, projection.selectableResults, "next");
+ }
+
+ const targetId = workspaces[150].id;
+ expect(activeId).toBe(targetId);
+ expect(projection.rowIndexByResultId.get(targetId)).toBe(151);
+ expect(projection.offsets[151]).toBe(32 + 150 * 56);
+ });
+});
diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts
new file mode 100644
index 000000000..ab2f3a837
--- /dev/null
+++ b/packages/app/src/command-center/results.ts
@@ -0,0 +1,181 @@
+import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
+import type { CommandCenterContribution } from "./contributions";
+
+export interface CommandCenterWorkspaceResult {
+ kind: "workspace";
+ id: string;
+ title: string;
+ subtitle: string;
+ searchText: string;
+ run(): void;
+}
+
+export interface CommandCenterAgentResult {
+ kind: "agent";
+ id: string;
+ agent: AggregatedAgent;
+ title: string;
+ subtitle: string;
+ searchText: string;
+ run(): void;
+}
+
+export interface CommandCenterContributionResult {
+ kind: "contribution";
+ id: string;
+ contribution: CommandCenterContribution;
+ searchText: string;
+ run(): void | Promise;
+}
+
+export type CommandCenterResult =
+ | CommandCenterWorkspaceResult
+ | CommandCenterAgentResult
+ | CommandCenterContributionResult;
+
+export interface CommandCenterResultSection {
+ id: string;
+ rank: number;
+ title?: string;
+ results: readonly CommandCenterResult[];
+}
+
+interface MutableCommandCenterResultSection {
+ id: string;
+ rank: number;
+ title?: string;
+ results: CommandCenterResult[];
+}
+
+export type CommandCenterListRow =
+ | { kind: "section"; key: string; title?: string; divider: boolean; height: number }
+ | { kind: "result"; key: string; result: CommandCenterResult; height: number };
+
+export interface CommandCenterListProjection {
+ rows: readonly CommandCenterListRow[];
+ selectableResults: readonly CommandCenterResult[];
+ rowIndexByResultId: ReadonlyMap;
+ offsets: readonly number[];
+}
+
+function matchesQuery(searchText: string, query: string): boolean {
+ const normalized = query.trim().toLowerCase();
+ return !normalized || searchText.includes(normalized);
+}
+
+function contributionSearchText(contribution: CommandCenterContribution): string {
+ const presentationText =
+ contribution.presentation.kind === "action"
+ ? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
+ : contribution.presentation.path;
+ return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
+}
+
+function resultHeight(result: CommandCenterResult): number {
+ if (result.kind === "workspace" || result.kind === "agent") return 56;
+ if (result.contribution.presentation.kind === "action") {
+ return result.contribution.presentation.subtitle ? 56 : 36;
+ }
+ return 36;
+}
+
+export function buildContributionSections(
+ contributions: readonly CommandCenterContribution[],
+ query: string,
+): CommandCenterResultSection[] {
+ const groups = new Map();
+ const hasQuery = Boolean(query.trim());
+
+ for (const contribution of contributions) {
+ if (contribution.visibility === "query" && !hasQuery) continue;
+ const searchText = contributionSearchText(contribution);
+ if (!matchesQuery(searchText, query)) continue;
+ const existing = groups.get(contribution.group);
+ const title =
+ contribution.presentation.kind === "action"
+ ? contribution.presentation.sectionTitle
+ : undefined;
+ const section = existing ?? {
+ id: contribution.group,
+ rank: contribution.groupRank,
+ title,
+ results: [],
+ };
+ section.results.push({
+ kind: "contribution",
+ id: contribution.id,
+ contribution,
+ searchText,
+ run: contribution.run,
+ });
+ groups.set(contribution.group, section);
+ }
+
+ return [...groups.values()].sort(
+ (left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
+ );
+}
+
+export function projectCommandCenterRows(
+ sections: readonly CommandCenterResultSection[],
+): CommandCenterListProjection {
+ const populated = sections
+ .filter((section) => section.results.length > 0)
+ .sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
+ const rows: CommandCenterListRow[] = [];
+ const selectableResults: CommandCenterResult[] = [];
+ const rowIndexByResultId = new Map();
+ const offsets: number[] = [];
+ let offset = 0;
+
+ for (const [sectionIndex, section] of populated.entries()) {
+ const divider = sectionIndex > 0;
+ let sectionHeight = 0;
+ if (section.title && divider) sectionHeight = 49;
+ if (section.title && !divider) sectionHeight = 32;
+ if (!section.title && divider) sectionHeight = 17;
+ if (sectionHeight > 0) {
+ offsets.push(offset);
+ rows.push({
+ kind: "section",
+ key: `section:${section.id}`,
+ title: section.title,
+ divider,
+ height: sectionHeight,
+ });
+ offset += sectionHeight;
+ }
+ for (const result of section.results) {
+ const height = resultHeight(result);
+ offsets.push(offset);
+ rowIndexByResultId.set(result.id, rows.length);
+ rows.push({ kind: "result", key: result.id, result, height });
+ selectableResults.push(result);
+ offset += height;
+ }
+ }
+
+ return { rows, selectableResults, rowIndexByResultId, offsets };
+}
+
+export function preserveActiveResultId(
+ activeId: string | null,
+ results: readonly CommandCenterResult[],
+): string | null {
+ if (activeId && results.some((result) => result.id === activeId)) return activeId;
+ return results[0]?.id ?? null;
+}
+
+export function moveActiveResultId(
+ activeId: string | null,
+ results: readonly CommandCenterResult[],
+ direction: "next" | "previous",
+): string | null {
+ if (results.length === 0) return null;
+ const current = results.findIndex((result) => result.id === activeId);
+ const delta = direction === "next" ? 1 : -1;
+ let start = current;
+ if (current < 0 && direction === "previous") start = 0;
+ const next = (start + delta + results.length) % results.length;
+ return results[next].id;
+}
diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx
deleted file mode 100644
index 49cafb624..000000000
--- a/packages/app/src/components/command-center.tsx
+++ /dev/null
@@ -1,726 +0,0 @@
-import {
- Modal,
- Pressable,
- ScrollView,
- Text,
- TextInput,
- View,
- type PressableStateCallbackType,
-} from "react-native";
-import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
-import { useTranslation } from "react-i18next";
-import { Folder, Home, Plus, Settings } from "lucide-react-native";
-import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
-import {
- useCommandCenter,
- type CommandCenterActionItem,
- type CommandCenterAgentItem,
- type CommandCenterItem,
- type CommandCenterWorkspaceItem,
-} from "@/hooks/use-command-center";
-import { AgentStatusDot } from "@/components/agent-status-dot";
-import { Shortcut } from "@/components/ui/shortcut";
-import { isNative, isWeb } from "@/constants/platform";
-import { useIsCompactFormFactor } from "@/constants/layout";
-import {
- IsolatedBottomSheetModal,
- useIsolatedBottomSheetVisibility,
-} from "@/components/ui/isolated-bottom-sheet-modal";
-import {
- BottomSheetBackdrop,
- BottomSheetScrollView,
- BottomSheetTextInput,
-} from "@gorhom/bottom-sheet";
-
-const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
- placeholderTextColor: theme.colors.foregroundMuted,
-}));
-const ThemedFolder = withUnistyles(Folder, (theme) => ({
- color: theme.colors.foregroundMuted,
-}));
-
-interface CommandCenterRowProps {
- active: boolean;
- children: ReactNode;
- onPress: () => void;
- registerRow: (el: View | null) => void;
- onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
-}
-
-const CommandCenterRow = memo(function CommandCenterRow({
- active,
- children,
- onPress,
- registerRow,
- onLayout,
-}: CommandCenterRowProps) {
- const { theme } = useUnistyles();
-
- const pressableStyle = useCallback(
- ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
- styles.row,
- (Boolean(hovered) || pressed || active) && {
- backgroundColor: theme.colors.surface1,
- },
- ],
- [active, theme.colors.surface1],
- );
-
- return (
-
- {children}
-
- );
-});
-
-interface CommandCenterRowContainerProps {
- item: CommandCenterItem;
- rowIndex: number;
- active: boolean;
- rowRefs: React.MutableRefObject