Add command center to mobile

Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
This commit is contained in:
Mohamed Boudra
2026-06-07 14:07:14 +07:00
parent db4376d17a
commit 9c16bc474b
2 changed files with 269 additions and 83 deletions
+237 -67
View File
@@ -9,24 +9,39 @@ import {
} from "react-native"; } from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Home, Plus, Settings } from "lucide-react-native"; import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center"; import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform"; 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";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
} }
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps { interface CommandCenterRowProps {
active: boolean; active: boolean;
children: ReactNode; children: ReactNode;
onPress: () => void; onPress: () => void;
registerRow: (el: View | null) => void; registerRow: (el: View | null) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
} }
const CommandCenterRow = memo(function CommandCenterRow({ const CommandCenterRow = memo(function CommandCenterRow({
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
children, children,
onPress, onPress,
registerRow, registerRow,
onLayout,
}: CommandCenterRowProps) { }: CommandCenterRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
); );
return ( return (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}> <Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children} {children}
</Pressable> </Pressable>
); );
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
active: boolean; active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void; onPress: () => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode; children: ReactNode;
} }
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
active, active,
rowRefs, rowRefs,
onPress, onPress,
onLayout,
children, children,
}: CommandCenterRowContainerProps) { }: CommandCenterRowContainerProps) {
const registerRow = useCallback( const registerRow = useCallback(
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
[rowRefs, rowIndex], [rowRefs, rowIndex],
); );
return ( return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}> <CommandCenterRow
active={active}
registerRow={registerRow}
onPress={onPress}
onLayout={onLayout}
>
{children} {children}
</CommandCenterRow> </CommandCenterRow>
); );
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
rowIndex: number; rowIndex: number;
active: boolean; active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void; onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
} }
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
rowIndex, rowIndex,
active, active,
rowRefs, rowRefs,
onLayout,
onSelect, onSelect,
}: CommandCenterActionRowProps) { }: CommandCenterActionRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
active={active} active={active}
rowRefs={rowRefs} rowRefs={rowRefs}
onPress={handlePress} onPress={handlePress}
onLayout={onLayout}
> >
<View style={styles.rowContent}> <View style={styles.rowContent}>
<View style={styles.rowMain}> <View style={styles.rowMain}>
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
rowIndex: number; rowIndex: number;
active: boolean; active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void; onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode; children: ReactNode;
} }
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
rowIndex, rowIndex,
active, active,
rowRefs, rowRefs,
onLayout,
onSelect, onSelect,
item, item,
children, children,
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
active={active} active={active}
rowRefs={rowRefs} rowRefs={rowRefs}
onPress={handlePress} onPress={handlePress}
onLayout={onLayout}
> >
{children} {children}
</CommandCenterRowContainer> </CommandCenterRowContainer>
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
actionItemsLength: number; actionItemsLength: number;
activeIndex: number; activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void; onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"]; sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"]; sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
@@ -219,6 +251,7 @@ function AgentItemsSection({
actionItemsLength, actionItemsLength,
activeIndex, activeIndex,
rowRefs, rowRefs,
onRowLayout,
onSelect, onSelect,
sectionDividerStyle, sectionDividerStyle,
sectionLabelStyle, sectionLabelStyle,
@@ -237,6 +270,7 @@ function AgentItemsSection({
rowIndex={rowIndex} rowIndex={rowIndex}
active={rowIndex === activeIndex} active={rowIndex === activeIndex}
rowRefs={rowRefs} rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect} onSelect={onSelect}
> >
<CommandCenterAgentRowContent agent={agent} /> <CommandCenterAgentRowContent agent={agent} />
@@ -249,50 +283,119 @@ function AgentItemsSection({
export function CommandCenter() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } = const {
useCommandCenter(); 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 rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null); 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(() => { useEffect(() => {
if (!open) { if (showBottomSheet && open) {
return; const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
} }
const row = rowRefs.current.get(activeIndex); }, [showBottomSheet, open]);
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) { const renderBackdrop = useCallback(
rowEl.scrollIntoView?.({ block: "nearest" }); (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; return;
} }
const rowTop = rowEl.offsetTop; // Native: use onLayout-measured positions
const rowBottom = rowTop + rowEl.offsetHeight; const layout = rowLayouts.current.get(activeIndex);
const visibleTop = scrollNode.scrollTop; if (!layout || !resultsRef.current) return;
const visibleBottom = visibleTop + scrollNode.clientHeight;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) { if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop; resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
return; } else if (rowBottom > visibleBottom) {
} resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
if (rowBottom > visibleBottom) { animated: true,
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight; });
} }
}, [activeIndex, open]); }, [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 actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]); const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
@@ -324,8 +427,101 @@ export function CommandCenter() {
[theme.colors.border], [theme.colors.border],
); );
if (isNative || !open) return null; 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}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.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
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="Type a command or search agents..."
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 ( return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}> <Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}> <View style={styles.overlay}>
@@ -354,39 +550,7 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{items.length === 0 ? ( {resultList}
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
)}
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
@@ -395,6 +559,12 @@ export function CommandCenter() {
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: "flex-start", justifyContent: "flex-start",
+32 -16
View File
@@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id"; import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -319,33 +320,25 @@ export function useCommandCenter() {
} }
}, [activeIndex, items.length, open]); }, [activeIndex, items.length, open]);
useEffect(() => { const handleKeyEvent = useCallback(
if (!open) return; (key: string): boolean => {
if (!open) return false;
const handler = (event: KeyboardEvent) => {
const currentItems = itemsRef.current; const currentItems = itemsRef.current;
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
if (key === "Escape") { if (key === "Escape") {
event.preventDefault();
handleCloseRef.current(); handleCloseRef.current();
return; return true;
} }
if (key === "Enter") { if (key === "Enter") {
if (currentItems.length === 0) return; if (currentItems.length === 0) return false;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1)); const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]); handleSelectItemRef.current(currentItems[index]);
return; return true;
} }
if (key === "ArrowDown" || key === "ArrowUp") { if (key === "ArrowDown" || key === "ArrowUp") {
if (currentItems.length === 0) return; if (currentItems.length === 0) return false;
event.preventDefault();
setActiveIndex((current) => { setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1; const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta; const next = current + delta;
@@ -353,13 +346,35 @@ export function useCommandCenter() {
if (next >= currentItems.length) return 0; if (next >= currentItems.length) return 0;
return next; 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. // react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true); window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true);
}, [open]); }, [open, handleKeyEvent]);
return { return {
open, open,
@@ -371,5 +386,6 @@ export function useCommandCenter() {
items, items,
handleClose, handleClose,
handleSelectItem, handleSelectItem,
handleKeyEvent,
}; };
} }