Files
paseo/packages/app/src/command-center/command-center.tsx
T
Mohamed Boudra a361d1cb2f fix(app): polish workspace navigation surfaces
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
2026-08-03 21:40:21 +02:00

764 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
FlatList,
Modal,
Pressable,
Text,
TextInput,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder } 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 { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { useCommandCenterContributions } from "./provider";
import {
buildContributionSections,
joinSubtitleParts,
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 COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
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 limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { projects } = useProjects({ enabled: open });
const showHost = 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 = joinSubtitleParts([
showHost ? host.serverName : null,
project.projectName,
workspace.currentBranch,
]);
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 = limitDefaultCategoryResults(
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
query,
);
const agentResults = limitDefaultCategoryResults(
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 = joinSubtitleParts([
showHost ? agent.serverLabel : null,
location,
formatTimeAgo(agent.lastActivityAt),
]);
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)),
query,
);
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, showHost, 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]);
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 choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
? result.contribution.presentation
: null;
const accessibilityLabel = choice?.path.join(" › ");
const accessibilityState = useMemo(
() => (isNative && choice ? { selected: choice.selected } : undefined),
[choice],
);
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}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? choice?.selected : undefined}
testID={choice?.testId}
>
<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}
testID="command-center-workspace-subtitle"
>
{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}>
<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 modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open,
isEnabled: showBottomSheet,
onClose: state.close,
});
const revealActiveResult = useCallback(() => {
if (!state.open || !state.activeId) return;
const index = state.rowIndexByResultId.get(state.activeId);
if (index === undefined) return;
const { offset, visibleLength } = scrollMetricsRef.current;
if (visibleLength <= 0) return;
const rowTop = state.offsets[index];
const rowBottom = rowTop + state.rows[index].height;
let nextOffset: number | null = null;
if (rowTop < offset) nextOffset = rowTop;
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
if (nextOffset === null) return;
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
const boundedOffset = Math.max(0, nextOffset);
scrollMetricsRef.current.offset = boundedOffset;
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
}, [
showBottomSheet,
state.activeId,
state.offsets,
state.open,
state.rowIndexByResultId,
state.rows,
]);
useEffect(() => {
if (!state.open) {
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
return;
}
revealActiveResult();
}, [revealActiveResult, state.open]);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
revealActiveResult();
},
[revealActiveResult],
);
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
}, []);
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,
onLayout: handleListLayout,
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
layer: modalLayer,
onKeyDown: handleWebOverlayKeyDown,
});
const backdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
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 (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} 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>
</OverlayLayerProvider>
);
}
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: 18,
flexShrink: 1,
},
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
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" },
}));