Switch models from the Command Center (#2147)

* Switch models from the Command Center

Add a model switcher to the Command-K Command Center. Typing surfaces a
flat, filterable list of "Model › Provider › Name" breadcrumb rows with
provider icons:

- Running agent: its own provider's models (a live agent can't change
  provider); selecting calls setAgentModel.
- New draft tab: every available provider's models in one flat list;
  selecting sets provider + model on the draft via a focused-draft
  controller published to a global store (the draft form state is local
  to the composer subtree and otherwise unreachable from the global
  Command Center).

Models only appear once the user starts typing, so the default palette
view is unchanged. Reuses useProvidersSnapshot and the existing
setAgentModel RPC — no protocol changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update packages/app/src/components/command-center.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update packages/app/src/hooks/use-command-center.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* refactor(app): make Command Center extensible

Let focused features register stable actions while the palette owns search, selection, and a single virtualized result projection.

* fix(app): save model preference after agent switch

Persist the shared model choice only after the daemon confirms the live agent switched successfully.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Michał Kędrzyński
2026-07-18 16:54:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Mohamed Boudra
parent 7917532716
commit 45bbb973a3
25 changed files with 1636 additions and 1227 deletions
@@ -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 <ThemedPlus size={size} strokeWidth={2.4} />;
}
function SettingsIcon({ size }: CommandCenterIconProps) {
return <ThemedSettings size={size} strokeWidth={2.2} />;
}
function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const platform = {
isMac: getShortcutOs() === "mac",
isDesktop: getIsElectronRuntime(),
};
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
}
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
const actions = useMemo<CommandCenterContribution[]>(
() => [
{
id: "new-agent",
group: "actions",
groupRank: 0,
rank: 0,
keywords: ["open", "project", "folder", "workspace", "repo"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
openAddProject();
},
presentation: {
kind: "action",
title: t("shell.commandCenter.addProject"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PlusIcon,
shortcutKeys: resolveActionShortcutKeys("new-agent", overrides),
},
},
{
id: "home",
group: "actions",
groupRank: 0,
rank: 1,
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(homeRoute);
},
presentation: {
kind: "action",
title: t("shell.commandCenter.home"),
sectionTitle: t("shell.commandCenter.actions"),
icon: HomeIcon,
},
},
{
id: "settings",
group: "actions",
groupRank: 0,
rank: 2,
keywords: ["settings", "preferences", "config", "configuration"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
router.push(settingsRoute);
},
presentation: {
kind: "action",
title: t("sidebar.actions.settings"),
sectionTitle: t("shell.commandCenter.actions"),
icon: SettingsIcon,
},
},
],
[homeRoute, openAddProject, overrides, settingsRoute, t],
);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
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<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 = [
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<string, number>;
offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>;
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<TextInput>(null);
const previousOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(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 (
<Pressable style={style} onPress={press}>
<ResultContent result={result} />
</Pressable>
);
});
function ResultContent({ result }: { result: CommandCenterResult }) {
if (result.kind === "agent") {
const agent = result.agent;
return (
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1} testID="command-center-agent-subtitle">
{result.subtitle}
</Text>
</View>
</View>
</View>
);
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{result.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{result.subtitle}
</Text>
</View>
</View>
</View>
);
}
const presentation = result.contribution.presentation;
const Icon = presentation.icon;
if (presentation.kind === "action") {
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{Icon ? (
<View style={styles.iconSlot}>
<Icon size={16} />
</View>
) : null}
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{presentation.title}
</Text>
{presentation.subtitle ? (
<Text style={styles.subtitle}>{presentation.subtitle}</Text>
) : null}
</View>
</View>
{presentation.shortcutKeys ? (
<Shortcut chord={presentation.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
);
}
return (
<View style={styles.rowContent} testID={presentation.testId}>
<View style={styles.rowMain}>
{Icon ? (
<View style={styles.iconSlot}>
<Icon size={16} />
</View>
) : null}
<View style={styles.breadcrumb}>
{presentation.path.map((part, index) => (
<View
key={presentation.path.slice(0, index + 1).join("\u0000")}
style={styles.breadcrumbPart}
>
{index > 0 ? <ThemedChevronRight size={13} strokeWidth={2} /> : null}
<Text
style={
index === presentation.path.length - 1 ? styles.title : styles.breadcrumbGroup
}
numberOfLines={1}
>
{part}
</Text>
</View>
))}
</View>
</View>
{presentation.selected ? (
<View style={styles.iconSlot}>
<ThemedCheck size={16} strokeWidth={2.2} />
</View>
) : null}
</View>
);
}
function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "section" }> }) {
let sizeStyle = styles.dividerSection;
if (row.title && row.divider) sizeStyle = styles.dividedSection;
if (row.title && !row.divider) sizeStyle = styles.titledSection;
return (
<View style={sizeStyle}>
{row.divider ? <View style={styles.sectionDivider} /> : null}
{row.title ? <Text style={styles.sectionLabel}>{row.title}</Text> : null}
</View>
);
}
export function CommandCenter() {
const { t } = useTranslation();
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(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<CommandCenterListRow>) =>
item.kind === "section" ? (
<SectionRow row={item} />
) : (
<ResultRow
result={item.result}
active={item.result.id === state.activeId}
onSelect={state.select}
/>
),
[state.activeId, state.select],
);
const getItemLayout = useCallback(
(_data: ArrayLike<CommandCenterListRow> | 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(
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
[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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={backdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.sheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
value={state.query}
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View testID="command-center-panel" style={styles.panel}>
<View style={styles.header}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
value={state.query}
onChangeText={state.setQuery}
placeholder={t("shell.commandCenter.placeholder")}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
</View>
</View>
</Modal>
);
}
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" },
}));
@@ -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<CommandCenterIconProps>;
interface CommandCenterContributionBase {
id: string;
group: string;
groupRank: number;
rank: number;
keywords: readonly string[];
visibility: "always" | "query";
run(): void | Promise<void>;
}
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[];
}
@@ -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([]);
});
});
@@ -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;
}
@@ -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<AgentProvider, CommandCenterIcon>();
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 <ProviderIcon size={size} />;
}
commandCenterProviderIcons.set(provider, CommandCenterProviderIcon);
return CommandCenterProviderIcon;
}
@@ -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<CommandCenterRegistry | null>(null);
export function CommandCenterProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<CommandCenterRegistry | null>(null);
if (!registryRef.current) registryRef.current = createCommandCenterRegistry();
return (
<CommandCenterRegistryContext.Provider value={registryRef.current}>
{children}
</CommandCenterRegistryContext.Provider>
);
}
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]);
}
@@ -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");
});
});
+100
View File
@@ -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<string, ActiveRegistration>();
const listeners = new Set<() => void>();
let snapshot = EMPTY_SNAPSHOT;
function publish(): void {
const contributions: CommandCenterContribution[] = [];
const ids = new Set<string>();
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<string>();
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();
},
};
}
@@ -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<typeof buildContributionSections>): 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);
});
});
+181
View File
@@ -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<void>;
}
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<string, number>;
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<string, MutableCommandCenterResultSection>();
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<string, number>();
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;
}