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
parent 7917532716
commit 45bbb973a3
25 changed files with 1636 additions and 1227 deletions

View File

@@ -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
<RosettaCalloutSource />
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return content;
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({

View File

@@ -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" },
}));

View File

@@ -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[];
}

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -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");
});
});

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();
},
};
}

View File

@@ -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);
});
});

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;
}

View File

@@ -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 (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children}
</Pressable>
);
});
interface CommandCenterRowContainerProps {
item: CommandCenterItem;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: CommandCenterItem) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode;
}
function CommandCenterRowContainer({
item,
rowIndex,
active,
rowRefs,
onSelect,
onLayout,
children,
}: CommandCenterRowContainerProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const registerRow = useCallback(
(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
},
[rowRefs, rowIndex],
);
return (
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRow>
);
}
interface CommandCenterActionRowProps {
item: CommandCenterActionItem;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
}
function CommandCenterActionRow({
item,
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
let actionIcon: React.ReactNode = null;
if (item.icon === "plus") {
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (item.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (item.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<CommandCenterRowContainer
item={item}
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onSelect={onSelect}
onLayout={onLayout}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{item.title}
</Text>
</View>
</View>
{item.shortcutKeys ? (
<Shortcut chord={item.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowContentProps {
item: CommandCenterAgentItem;
}
function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const agent = item.agent;
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
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={titleStyle} numberOfLines={1}>
{item.title}
</Text>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{item.subtitle}
</Text>
</View>
</View>
</View>
);
}
interface AgentItemsSectionProps {
agentItems: CommandCenterAgentItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function AgentItemsSection({
agentItems,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => {
const rowIndex = startIndex + index;
const agent = item.agent;
return (
<CommandCenterRowContainer
key={`${agent.serverId}:${agent.id}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent item={item} />
</CommandCenterRowContainer>
);
})}
</>
);
}
interface WorkspaceItemsSectionProps {
workspaceItems: CommandCenterWorkspaceItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function WorkspaceItemsSection({
workspaceItems,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: WorkspaceItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.workspaces")}</Text>
{workspaceItems.map((item, index) => {
const rowIndex = startIndex + index;
return (
<CommandCenterRowContainer
key={`${item.serverId}:${item.workspaceId}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onSelect={onSelect}
onLayout={onRowLayout(rowIndex)}
>
<View
style={styles.rowContent}
testID={`command-center-workspace-${item.serverId}:${item.workspaceId}`}
>
<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}>
{item.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{item.subtitle}
</Text>
</View>
</View>
</View>
</CommandCenterRowContainer>
);
})}
</>
);
}
export function CommandCenter() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null);
const nativeScrollY = useRef(0);
const nativeViewHeight = useRef(0);
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bottomSheetInputRef = useRef<any>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: showBottomSheet,
onClose: handleClose,
});
// Focus the bottom sheet input when the sheet opens on mobile
useEffect(() => {
if (showBottomSheet && open) {
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
}
}, [showBottomSheet, open]);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
// Scroll active row into view
useEffect(() => {
if (!open) return;
if (isWeb) {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") return;
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
return;
}
// Native: use onLayout-measured positions
const layout = rowLayouts.current.get(activeIndex);
if (!layout || !resultsRef.current) return;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) {
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
} else if (rowBottom > visibleBottom) {
resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
animated: true,
});
}
}, [activeIndex, open]);
const handleRowLayout = useCallback(
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
rowLayouts.current.set(rowIndex, {
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
},
[],
);
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const workspaceItems = useMemo(() => items.filter((item) => item.kind === "workspace"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
const panelStyle = useMemo(
() => [
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
],
[theme.colors.border, theme.colors.surface0],
);
const headerStyle = useMemo(
() => [styles.header, { borderBottomColor: theme.colors.border }],
[theme.colors.border],
);
const inputStyle = useMemo(
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionLabelStyle = useMemo(
() => [styles.sectionLabel, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionDividerStyle = useMemo(
() => [styles.sectionDivider, { backgroundColor: theme.colors.border }],
[theme.colors.border],
);
const sheetBackgroundStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
const sheetHandleStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
handleKeyEvent(key);
},
[handleKeyEvent],
);
const handleSubmitEditing = useCallback(() => {
handleKeyEvent("Enter");
}, [handleKeyEvent]);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{workspaceItems.length > 0 ? (
<WorkspaceItemsSection
workspaceItems={workspaceItems}
startIndex={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
startIndex={actionItems.length + workspaceItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
);
// Mobile: bottom sheet
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef as unknown as React.Ref<never>}
value={query}
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder={t("shell.commandCenter.placeholder")}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
);
}
if (!open) return null;
// Desktop web: centered overlay panel
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View testID="command-center-panel" style={panelStyle}>
<View style={headerStyle}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder={t("shell.commandCenter.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
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,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
results: {
flexGrow: 0,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingTop: 0,
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
},
row: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
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],
},
iconSlot: {
width: 16,
height: 20,
alignItems: "center",
justifyContent: "center",
},
textContent: {
flex: 1,
minWidth: 0,
gap: 2,
},
rowShortcut: {
marginLeft: theme.spacing[2],
flexShrink: 0,
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
color: theme.colors.foreground,
},
subtitle: {
fontSize: theme.fontSize.xs,
lineHeight: 18,
color: theme.colors.foregroundMuted,
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
fontSize: theme.fontSize.base,
},
}));

View File

@@ -62,6 +62,9 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { useCommandCenterActions } from "@/command-center/provider";
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
interface AgentControlOption {
id: string;
@@ -130,6 +133,7 @@ export interface DraftAgentControlsProps {
interface AgentControlsProps {
agentId: string;
serverId: string;
isPaneFocused: boolean;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
@@ -1362,9 +1366,11 @@ function ThinkingComboboxOption({
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
isPaneFocused,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
@@ -1436,29 +1442,49 @@ export const AgentControls = memo(function AgentControls({
const activeModelId = modelSelection.activeModelId;
const handleSelectModel = useCallback(
(modelId: string) => {
async (modelId: string) => {
if (!client || !agentProvider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agentProvider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentControls] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentControls] setAgentModel failed", error);
try {
await client.setAgentModel(agentId, modelId);
await updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: agentProvider,
updates: {
model: modelId,
},
}),
);
} catch (error) {
console.warn("[AgentControls] setAgentModel or persist preference failed", error);
toast.error(toErrorMessage(error));
});
}
},
[agentId, agentProvider, client, toast, updatePreferences],
);
const commandCenterModelActions = useMemo(
() =>
buildModelChoiceContributions({
serverId,
providers: agentModelSelectorProviders,
selectedProvider: agentProvider ?? null,
selectedModelId: activeModelId,
groupLabel: t("shell.commandCenter.modelGroupLabel"),
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
getIcon: getCommandCenterProviderIcon,
select: (_provider, modelId) => handleSelectModel(modelId),
}),
[activeModelId, agentModelSelectorProviders, agentProvider, handleSelectModel, serverId, t],
);
useCommandCenterActions({
sourceId: `agent:${serverId}:${agentId}`,
enabled: isPaneFocused && Boolean(client),
actions: commandCenterModelActions,
});
const handleToggleFavoriteModel = useCallback(
(provider: string, modelId: string) => {
void updatePreferences((current) =>

View File

@@ -24,6 +24,9 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useCommandCenterActions } from "@/command-center/provider";
import { buildModelChoiceContributions } from "@/command-center/model-contributions";
import { getCommandCenterProviderIcon } from "@/command-center/provider-icon";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
@@ -375,6 +378,28 @@ export function WorkspaceDraftAgentTab({
if (!composerState) {
throw new Error("Workspace draft composer state is required");
}
const draftModelActions = useMemo(
() =>
buildModelChoiceContributions({
serverId,
providers: composerState.modelSelectorProviders,
selectedProvider: composerState.selectedProvider,
selectedModelId: composerState.effectiveModelId || null,
groupLabel: t("shell.commandCenter.modelGroupLabel"),
searchKeywords: t("shell.commandCenter.modelSearchKeywords"),
getIcon: getCommandCenterProviderIcon,
select: composerState.setProviderAndModelFromUser,
}),
[
composerState.effectiveModelId,
composerState.modelSelectorProviders,
composerState.selectedProvider,
composerState.setProviderAndModelFromUser,
serverId,
t,
],
);
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const setDraftAttachments = draftInput.setAttachments;
@@ -517,6 +542,11 @@ export function WorkspaceDraftAgentTab({
onCreated(result);
},
});
useCommandCenterActions({
sourceId: `draft:${serverId}:${tabId}`,
enabled: isPaneFocused && !isSubmitting,
actions: draftModelActions,
});
const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit &&

View File

@@ -258,10 +258,11 @@ interface RenderLeftContentArgs {
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
isPaneFocused: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
@@ -269,6 +270,7 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
<AgentControls
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
@@ -1845,8 +1847,9 @@ export function Composer({
serverId,
focusInput,
isCompactLayout,
isPaneFocused,
}),
[agentControls, agentId, focusInput, isCompactLayout, serverId],
[agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {

View File

@@ -1,482 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { isWeb } from "@/constants/platform";
import { useProjects } from "@/hooks/use-projects";
import { useHosts } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = [];
const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = [];
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
function buildSearchText(...fields: string[]): string {
return fields.join(" ").toLowerCase();
}
function matchesQuery(searchText: string, query: string): boolean {
const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
}
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();
}
interface CommandCenterActionDefinition {
id: string;
titleKey:
| "shell.commandCenter.addProject"
| "shell.commandCenter.home"
| "sidebar.actions.settings";
icon?: "plus" | "settings" | "home";
actionId?: string;
keywords: string[];
routeKind: "settings" | "home" | "none";
}
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
{
id: "new-agent",
titleKey: "shell.commandCenter.addProject",
icon: "plus",
actionId: "new-agent",
keywords: ["open", "project", "folder", "workspace", "repo"],
routeKind: "none",
},
{
id: "home",
titleKey: "shell.commandCenter.home",
icon: "home",
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
routeKind: "home",
},
{
id: "settings",
titleKey: "sidebar.actions.settings",
icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"],
routeKind: "settings",
},
];
export interface CommandCenterActionItem {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings" | "home";
route?: Href;
shortcutKeys?: ShortcutKey[][];
searchText: string;
}
export interface CommandCenterWorkspaceItem {
kind: "workspace";
serverId: string;
workspaceId: string;
title: string;
subtitle: string;
searchText: string;
}
export interface CommandCenterAgentItem {
kind: "agent";
agent: AggregatedAgent;
title: string;
subtitle: string;
searchText: string;
}
export type CommandCenterItem =
| CommandCenterActionItem
| CommandCenterWorkspaceItem
| CommandCenterAgentItem;
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const platform = { isMac, isDesktop: isDesktopApp };
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 useCommandCenter() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const openAddProject = useOpenAddProject();
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
const activeIndexRef = useRef(0);
const itemsRef = useRef<CommandCenterItem[]>([]);
const handleCloseRef = useRef<() => void>(() => undefined);
const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const hosts = useHosts();
const showAgentHost = hosts.length > 1;
const allWorkspaceItems = useMemo(() => {
const results: CommandCenterWorkspaceItem[] = [];
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;
results.push({
kind: "workspace",
serverId: host.serverId,
workspaceId: workspace.id,
title,
subtitle,
searchText: buildSearchText(title, subtitle),
});
}
}
}
results.sort((left, right) => {
const titleDelta = left.title.localeCompare(right.title, undefined, {
numeric: true,
sensitivity: "base",
});
if (titleDelta !== 0) return titleDelta;
const hostDelta = left.subtitle.localeCompare(right.subtitle, undefined, {
numeric: true,
sensitivity: "base",
});
if (hostDelta !== 0) return hostDelta;
return `${left.serverId}:${left.workspaceId}`.localeCompare(
`${right.serverId}:${right.workspaceId}`,
);
});
return results;
}, [projects]);
const workspaceTitleByKey = useMemo(
() =>
new Map(
allWorkspaceItems.map((workspace) => [
`${workspace.serverId}:${workspace.workspaceId}`,
workspace.title,
]),
),
[allWorkspaceItems],
);
const workspaceResults = useMemo(() => {
if (!open || allWorkspaceItems.length === 0) {
return EMPTY_WORKSPACE_ITEMS;
}
return allWorkspaceItems.filter((workspace) => matchesQuery(workspace.searchText, query));
}, [allWorkspaceItems, open, query]);
const agentResults = useMemo(() => {
if (!open || agents.length === 0) {
return EMPTY_AGENT_ITEMS;
}
const items = agents.map<CommandCenterAgentItem>((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",
agent,
title,
subtitle,
searchText: buildSearchText(title, subtitle, agent.cwd),
};
});
const filtered = items.filter((item) => matchesQuery(item.searchText, query));
filtered.sort((left, right) => sortAgents(left.agent, right.agent));
return filtered;
}, [agents, open, query, showAgentHost, t, workspaceTitleByKey]);
const settingsRoute = useMemo<Href>(() => {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
const actionItems = useMemo(() => {
if (!open) {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter(
(action) => action.routeKind !== "home" || Boolean(homeRoute),
)
.map<CommandCenterActionItem>((action) => {
let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute;
else if (action.routeKind === "home") route = homeRoute;
const title = t(action.titleKey);
return {
kind: "action",
id: action.id,
title,
icon: action.icon,
route,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
searchText: buildSearchText(title, ...action.keywords),
};
})
.filter((action) => matchesQuery(action.searchText, query));
}, [open, query, settingsRoute, homeRoute, overrides, t]);
const items = useMemo(() => {
if (!open) {
return EMPTY_COMMAND_CENTER_ITEMS;
}
return [...actionItems, ...workspaceResults, ...agentResults];
}, [actionItems, workspaceResults, agentResults, open]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigateToAgent({
serverId: agent.serverId,
agentId: agent.id,
});
},
[setOpen],
);
const handleSelectWorkspace = useCallback(
(workspace: CommandCenterWorkspaceItem) => {
didNavigateRef.current = true;
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigateToWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
},
[setOpen],
);
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
openAddProject();
return;
}
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
},
[openAddProject, setOpen],
);
const handleSelectItem = useCallback(
(item: CommandCenterItem) => {
if (item.kind === "action") {
handleSelectAction(item);
return;
}
if (item.kind === "workspace") {
handleSelectWorkspace(item);
return;
}
handleSelectAgent(item.agent);
},
[handleSelectAction, handleSelectAgent, handleSelectWorkspace],
);
useEffect(() => {
activeIndexRef.current = activeIndex;
}, [activeIndex]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
handleCloseRef.current = handleClose;
}, [handleClose]);
useEffect(() => {
handleSelectItemRef.current = handleSelectItem;
}, [handleSelectItem]);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (!open) {
setQuery("");
setActiveIndex(0);
if (prevOpen && !didNavigateRef.current) {
const el = takeCommandCenterFocusRestoreElement();
const isFocused = () =>
Boolean(el) && typeof document !== "undefined" && document.activeElement === el;
const cancel = focusWithRetries({
focus: () => el?.focus(),
isFocused,
onTimeout: () => {
keyboardActionDispatcher.dispatch({
id: "message-input.focus",
scope: "message-input",
});
},
});
return cancel;
}
return;
}
didNavigateRef.current = false;
const id = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [open]);
useEffect(() => {
if (!open) return;
if (activeIndex >= items.length) {
setActiveIndex(items.length > 0 ? items.length - 1 : 0);
}
}, [activeIndex, items.length, open]);
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
const currentItems = itemsRef.current;
if (key === "Escape") {
handleCloseRef.current();
return true;
}
if (key === "Enter") {
if (currentItems.length === 0) return false;
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]);
return true;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (currentItems.length === 0) return false;
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return currentItems.length - 1;
if (next >= currentItems.length) return 0;
return next;
});
return true;
}
return false;
},
[open],
);
useEffect(() => {
if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (handleKeyEvent(event.key)) {
event.preventDefault();
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open, handleKeyEvent]);
return {
open,
inputRef,
query,
setQuery,
activeIndex,
setActiveIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
};
}

View File

@@ -184,6 +184,10 @@ describe("translation resources", () => {
expect(en.shell.commandCenter.newAgent).toBe("New agent");
expect(en.shell.commandCenter.addProject).toBe("Add project");
expect(en.shell.commandCenter.home).toBe("Home");
expect(en.shell.commandCenter.modelGroupLabel).toBe("Model");
expect(en.shell.commandCenter.modelSearchKeywords).toBe(
"switch model change model set model select model",
);
});
it("includes composer and agent workflow keys for the Batch 2 migration", () => {

View File

@@ -62,6 +62,8 @@ export const ar: TranslationResources = {
newAgent: "وكيل جديد",
addProject: "إضافة مشروع",
home: "بيت",
modelGroupLabel: "النموذج",
modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج",
},
},
composer: {

View File

@@ -60,6 +60,8 @@ export const en = {
newAgent: "New agent",
addProject: "Add project",
home: "Home",
modelGroupLabel: "Model",
modelSearchKeywords: "switch model change model set model select model",
},
},
composer: {

View File

@@ -62,6 +62,8 @@ export const es: TranslationResources = {
newAgent: "Nuevo agente",
addProject: "Agregar proyecto",
home: "Hogar",
modelGroupLabel: "Modelo",
modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo",
},
},
composer: {

View File

@@ -63,6 +63,9 @@ export const fr: TranslationResources = {
newAgent: "Nouvel agent",
addProject: "Ajouter un projet",
home: "Maison",
modelGroupLabel: "Modèle",
modelSearchKeywords:
"changer de modèle modifier le modèle définir le modèle sélectionner le modèle",
},
},
composer: {

View File

@@ -62,6 +62,8 @@ export const ja: TranslationResources = {
newAgent: "新しいエージェント",
addProject: "プロジェクトを追加",
home: "ホーム",
modelGroupLabel: "モデル",
modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択",
},
},
composer: {

View File

@@ -62,6 +62,8 @@ export const ptBR: TranslationResources = {
newAgent: "Novo agente",
addProject: "Adicionar projeto",
home: "Início",
modelGroupLabel: "Modelo",
modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo",
},
},
composer: {

View File

@@ -62,6 +62,8 @@ export const ru: TranslationResources = {
newAgent: "Новый агент",
addProject: "Добавить проект",
home: "Дом",
modelGroupLabel: "Модель",
modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель",
},
},
composer: {

View File

@@ -62,6 +62,8 @@ export const zhCN: TranslationResources = {
newAgent: "新建 Agent",
addProject: "添加 project",
home: "首页",
modelGroupLabel: "模型",
modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型",
},
},
composer: {