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:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user