Files
paseo/packages/app/src/components/command-center.tsx
T
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

348 lines
11 KiB
TypeScript

import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } 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";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
type CommandCenterRowProps = {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
};
const CommandCenterRow = memo(function CommandCenterRow({
active,
children,
onPress,
registerRow,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
return (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
{children}
</Pressable>
);
});
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
useEffect(() => {
if (!open) {
return;
}
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;
}
}, [activeIndex, open]);
if (isNative || !open) return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
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={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{items.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
No matches
</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Actions
</Text>
{actionItems.map((item, index) => {
const active = index === activeIndex;
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
) : null;
return (
<CommandCenterRow
key={`action:${action.id}`}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(index, el);
else rowRefs.current.delete(index);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? (
<View style={styles.iconSlot}>{actionIcon}</View>
) : null}
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
);
})}
</>
) : null}
{agentItems.length > 0 ? (
<>
{actionItems.length > 0 ? (
<View
style={[styles.sectionDivider, { backgroundColor: theme.colors.border }]}
/>
) : null}
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Agents
</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItems.length + index;
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<CommandCenterRow
key={agentKey(agent)}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<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, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</CommandCenterRow>
);
})}
</>
) : null}
</>
)}
</ScrollView>
</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,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
},
input: {
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as any,
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.base,
fontWeight: "400",
lineHeight: 20,
},
subtitle: {
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
fontSize: theme.fontSize.base,
},
}));