From 9c16bc474b435e93f4763cd6b9310563c044c2f6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 7 Jun 2026 14:07:14 +0700 Subject: [PATCH] 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. --- .../app/src/components/command-center.tsx | 304 ++++++++++++++---- packages/app/src/hooks/use-command-center.ts | 48 ++- 2 files changed, 269 insertions(+), 83 deletions(-) diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 0947ff176..fcbadd289 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -9,24 +9,39 @@ import { } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; 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 type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { AgentStatusDot } from "@/components/agent-status-dot"; 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): string { return `${agent.serverId}:${agent.id}`; } +const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ + placeholderTextColor: 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({ @@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({ children, onPress, registerRow, + onLayout, }: CommandCenterRowProps) { const { theme } = useUnistyles(); @@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({ ); return ( - + {children} ); @@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps { active: boolean; rowRefs: React.MutableRefObject>; onPress: () => void; + onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; children: ReactNode; } @@ -67,6 +84,7 @@ function CommandCenterRowContainer({ active, rowRefs, onPress, + onLayout, children, }: CommandCenterRowContainerProps) { const registerRow = useCallback( @@ -77,7 +95,12 @@ function CommandCenterRowContainer({ [rowRefs, rowIndex], ); return ( - + {children} ); @@ -88,6 +111,7 @@ interface CommandCenterActionRowProps { rowIndex: number; active: boolean; rowRefs: React.MutableRefObject>; + onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; onSelect: (item: ReturnType["items"][number]) => void; } @@ -96,6 +120,7 @@ function CommandCenterActionRow({ rowIndex, active, rowRefs, + onLayout, onSelect, }: CommandCenterActionRowProps) { const { theme } = useUnistyles(); @@ -119,6 +144,7 @@ function CommandCenterActionRow({ active={active} rowRefs={rowRefs} onPress={handlePress} + onLayout={onLayout} > @@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps { rowIndex: number; active: boolean; rowRefs: React.MutableRefObject>; + onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; onSelect: (item: ReturnType["items"][number]) => void; children: ReactNode; } @@ -150,6 +177,7 @@ function CommandCenterAgentRow({ rowIndex, active, rowRefs, + onLayout, onSelect, item, children, @@ -161,6 +189,7 @@ function CommandCenterAgentRow({ active={active} rowRefs={rowRefs} onPress={handlePress} + onLayout={onLayout} > {children} @@ -209,6 +238,9 @@ interface AgentItemsSectionProps { actionItemsLength: number; activeIndex: number; rowRefs: React.MutableRefObject>; + onRowLayout: ( + rowIndex: number, + ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; onSelect: (item: ReturnType["items"][number]) => void; sectionDividerStyle: React.ComponentProps["style"]; sectionLabelStyle: React.ComponentProps["style"]; @@ -219,6 +251,7 @@ function AgentItemsSection({ actionItemsLength, activeIndex, rowRefs, + onRowLayout, onSelect, sectionDividerStyle, sectionLabelStyle, @@ -237,6 +270,7 @@ function AgentItemsSection({ rowIndex={rowIndex} active={rowIndex === activeIndex} rowRefs={rowRefs} + onLayout={onRowLayout(rowIndex)} onSelect={onSelect} > @@ -249,50 +283,119 @@ function AgentItemsSection({ export function CommandCenter() { const { theme } = useUnistyles(); - const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } = - useCommandCenter(); + const { + open, + inputRef, + query, + setQuery, + activeIndex, + items, + handleClose, + handleSelectItem, + handleKeyEvent, + } = useCommandCenter(); + + const isCompact = useIsCompactFormFactor(); + const showBottomSheet = isCompact && isNative; const rowRefs = useRef>(new Map()); + const rowLayouts = useRef>(new Map()); const resultsRef = useRef(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(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 (!open) { - return; + if (showBottomSheet && open) { + const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); + return () => clearTimeout(id); } - 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; + }, [showBottomSheet, open]); - if (!scrollNode) { - rowEl.scrollIntoView?.({ block: "nearest" }); + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + // 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; } - const rowTop = rowEl.offsetTop; - const rowBottom = rowTop + rowEl.offsetHeight; - const visibleTop = scrollNode.scrollTop; - const visibleBottom = visibleTop + scrollNode.clientHeight; + // 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) { - scrollNode.scrollTop = rowTop; - return; - } - - if (rowBottom > visibleBottom) { - scrollNode.scrollTop = rowBottom - scrollNode.clientHeight; + 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 agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]); @@ -324,8 +427,101 @@ export function CommandCenter() { [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 ? ( + No matches + ) : ( + <> + {actionItems.length > 0 ? ( + <> + Actions + {actionItems.map((item, index) => ( + + ))} + + ) : null} + + {agentItems.length > 0 ? ( + + ) : null} + + ); + + // Mobile: bottom sheet + if (showBottomSheet) { + return ( + + + } + value={query} + onChangeText={setQuery} + onKeyPress={handleKeyPress} + onSubmitEditing={handleSubmitEditing} + placeholder="Type a command or search agents..." + style={inputStyle} + autoCapitalize="none" + autoCorrect={false} + autoFocus + /> + + + {resultList} + + + ); + } + + if (!open) return null; + + // Desktop web: centered overlay panel return ( @@ -354,39 +550,7 @@ export function CommandCenter() { keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={false} > - {items.length === 0 ? ( - No matches - ) : ( - <> - {actionItems.length > 0 ? ( - <> - Actions - {actionItems.map((item, index) => ( - - ))} - - ) : null} - - {agentItems.length > 0 ? ( - - ) : null} - - )} + {resultList} @@ -395,6 +559,12 @@ export function CommandCenter() { } 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", diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 69706da70..4269cd804 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { focusWithRetries } from "@/utils/web-focus"; import { useActiveServerId } from "@/hooks/use-active-server-id"; +import { isWeb } from "@/constants/platform"; const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; @@ -319,33 +320,25 @@ export function useCommandCenter() { } }, [activeIndex, items.length, open]); - useEffect(() => { - if (!open) return; - - const handler = (event: KeyboardEvent) => { + const handleKeyEvent = useCallback( + (key: string): boolean => { + if (!open) return false; const currentItems = itemsRef.current; - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { - return; - } if (key === "Escape") { - event.preventDefault(); handleCloseRef.current(); - return; + return true; } if (key === "Enter") { - if (currentItems.length === 0) return; - event.preventDefault(); + if (currentItems.length === 0) return false; const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1)); handleSelectItemRef.current(currentItems[index]); - return; + return true; } if (key === "ArrowDown" || key === "ArrowUp") { - if (currentItems.length === 0) return; - event.preventDefault(); + if (currentItems.length === 0) return false; setActiveIndex((current) => { const delta = key === "ArrowDown" ? 1 : -1; const next = current + delta; @@ -353,13 +346,35 @@ export function useCommandCenter() { 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]); + }, [open, handleKeyEvent]); return { open, @@ -371,5 +386,6 @@ export function useCommandCenter() { items, handleClose, handleSelectItem, + handleKeyEvent, }; }