diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx
index 54062dfbd..2233cd688 100644
--- a/apps/mobile/src/app/automation/create.tsx
+++ b/apps/mobile/src/app/automation/create.tsx
@@ -9,13 +9,13 @@ import {
ScrollView,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/text";
import { TaskAutomationValidationError } from "@/features/tasks/api";
import { AutomationForm } from "@/features/tasks/components/AutomationForm";
import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations";
import { useSkillStoreSkill } from "@/features/tasks/skills/hooks";
import { formatSkillTemplateId } from "@/features/tasks/skills/skillTemplateIds";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
// Reserved space below the scrolling form content. Tall enough that the
@@ -35,7 +35,7 @@ export default function CreateAutomationScreen() {
: skillNameParam;
const router = useRouter();
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { insets, bottom } = useScreenInsets();
const createAutomation = useCreateTaskAutomation();
const defaultTimezone = useMemo(
() => getCalendars()[0]?.timeZone ?? "UTC",
@@ -209,7 +209,7 @@ export default function CreateAutomationScreen() {
{formMounted && (
("installed");
const [search, setSearch] = useState("");
@@ -149,7 +149,7 @@ export default function McpServersScreen() {
tintColor={themeColors.accent[9]}
/>
}
- contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
+ contentContainerStyle={{ paddingBottom: bottom("default") }}
/>
) : (
}
- contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
+ contentContainerStyle={{ paddingBottom: bottom("default") }}
/>
)}
diff --git a/apps/mobile/src/app/mcp-servers/installation/[id].tsx b/apps/mobile/src/app/mcp-servers/installation/[id].tsx
index dcae9d321..21c5edf5b 100644
--- a/apps/mobile/src/app/mcp-servers/installation/[id].tsx
+++ b/apps/mobile/src/app/mcp-servers/installation/[id].tsx
@@ -16,7 +16,6 @@ import {
Switch,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader";
import { ServerIcon } from "@/features/mcp/components/ServerIcon";
import {
@@ -31,6 +30,7 @@ import { reauthorizeInstallation } from "@/features/mcp/oauth";
import { getMcpConnectionManager } from "@/features/mcp/service";
import type { McpApprovalState } from "@/features/mcp/types";
import { isStdioServer } from "@/features/mcp/types";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
@@ -39,7 +39,7 @@ const log = logger.scope("mcp-installation-detail");
export default function McpInstallationDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { insets, bottom } = useScreenInsets();
const installations = useMcpInstallations();
const installation = useMemo(
@@ -142,7 +142,7 @@ export default function McpInstallationDetailScreen() {
className="flex-1"
contentContainerStyle={{
paddingTop: insets.top + 60,
- paddingBottom: insets.bottom + 24,
+ paddingBottom: bottom("default"),
paddingHorizontal: 16,
}}
refreshControl={
diff --git a/apps/mobile/src/app/mcp-servers/template/[id].tsx b/apps/mobile/src/app/mcp-servers/template/[id].tsx
index 2f37ba11a..607a88ce6 100644
--- a/apps/mobile/src/app/mcp-servers/template/[id].tsx
+++ b/apps/mobile/src/app/mcp-servers/template/[id].tsx
@@ -10,7 +10,6 @@ import {
TextInput,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FloatingMcpHeader } from "@/features/mcp/components/FloatingMcpHeader";
import { ServerIcon } from "@/features/mcp/components/ServerIcon";
import {
@@ -20,6 +19,7 @@ import {
} from "@/features/mcp/hooks";
import { installTemplateWithOAuth } from "@/features/mcp/oauth";
import { isStdioServer } from "@/features/mcp/types";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
@@ -28,7 +28,7 @@ const log = logger.scope("mcp-template-detail");
export default function McpTemplateDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { insets, bottom } = useScreenInsets();
const marketplace = useMcpMarketplace();
const installations = useMcpInstallations();
@@ -116,7 +116,7 @@ export default function McpTemplateDetailScreen() {
className="flex-1"
contentContainerStyle={{
paddingTop: insets.top + 60,
- paddingBottom: insets.bottom + 24,
+ paddingBottom: bottom("default"),
paddingHorizontal: 16,
}}
>
diff --git a/apps/mobile/src/app/pr-diff.tsx b/apps/mobile/src/app/pr-diff.tsx
index 37f10e8fd..bb895e606 100644
--- a/apps/mobile/src/app/pr-diff.tsx
+++ b/apps/mobile/src/app/pr-diff.tsx
@@ -1,18 +1,18 @@
import { Text } from "@components/text";
import { useLocalSearchParams } from "expo-router";
import { ActivityIndicator, FlatList, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FileDiff } from "@/features/tasks/components/FileDiff";
import {
type ChangedFile,
usePrChangedFiles,
} from "@/features/tasks/hooks/usePrChangedFiles";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
export default function PrDiffScreen() {
const { prUrl } = useLocalSearchParams<{ prUrl?: string }>();
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { bottom } = useScreenInsets();
const { data: files, isLoading } = usePrChangedFiles(prUrl ?? null);
@@ -47,7 +47,7 @@ export default function PrDiffScreen() {
contentContainerStyle={{
paddingHorizontal: 12,
paddingTop: 8,
- paddingBottom: insets.bottom + 16,
+ paddingBottom: bottom("default"),
}}
ListHeaderComponent={
diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx
index eb362d933..ce512e3e7 100644
--- a/apps/mobile/src/app/settings/index.tsx
+++ b/apps/mobile/src/app/settings/index.tsx
@@ -3,7 +3,6 @@ import { router } from "expo-router";
import { ArrowSquareOut, CaretRight, SpeakerHigh } from "phosphor-react-native";
import { useState } from "react";
import { Linking, Pressable, ScrollView, Switch, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAuthStore, useProjectsQuery, useUserQuery } from "@/features/auth";
import { useDismissedReportsStore } from "@/features/inbox/stores/dismissedReportsStore";
import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore";
@@ -19,6 +18,7 @@ import { SettingsRow } from "@/features/settings/components/SettingsRow";
import { SettingsSection } from "@/features/settings/components/SettingsSection";
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
import { playCompletionSound } from "@/features/tasks/utils/sounds";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
@@ -79,7 +79,7 @@ function taskModeLabel(mode: InitialTaskMode): string {
export default function SettingsScreen() {
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { insets, bottom } = useScreenInsets();
const {
logout,
@@ -170,7 +170,7 @@ export default function SettingsScreen() {
// padding clears the home indicator and gives breathing room past the last
// row so it never hides behind it.
const contentPaddingTop = insets.top + 60;
- const contentPaddingBottom = insets.bottom + 32;
+ const contentPaddingBottom = bottom("default");
return (
diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index 53de78337..4e57d3753 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -12,7 +12,6 @@ import {
} from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FloatingBackButton } from "@/components/FloatingBackButton";
import { getTask, runTaskInCloud } from "@/features/tasks/api";
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
@@ -36,6 +35,7 @@ import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore";
import { useTaskStore } from "@/features/tasks/stores/taskStore";
import type { Task } from "@/features/tasks/types";
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
@@ -59,7 +59,7 @@ export default function TaskDetailScreen() {
}>();
const router = useRouter();
const queryClient = useQueryClient();
- const insets = useSafeAreaInsets();
+ const { insets, composerBottom } = useScreenInsets();
const themeColors = useThemeColors();
const [task, setTask] = useState(null);
const [loading, setLoading] = useState(true);
@@ -123,9 +123,9 @@ export default function TaskDetailScreen() {
// height, so the composer sits at the keyboard top — no extra gap needed
// when open. Closed state keeps a comfortable bottom inset.
return {
- marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50),
+ marginBottom: height.value < 0 ? 0 : composerBottom(),
};
- }, [insets.bottom]);
+ }, [composerBottom]);
useEffect(() => {
if (!taskId) return;
diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx
index 3ca3cc3fc..62aab442e 100644
--- a/apps/mobile/src/app/task/index.tsx
+++ b/apps/mobile/src/app/task/index.tsx
@@ -28,8 +28,6 @@ import {
useReanimatedKeyboardAnimation,
} from "react-native-keyboard-controller";
import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-
import { useVoiceRecording } from "@/features/chat";
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
import { createTask, runTaskInCloud } from "@/features/tasks/api";
@@ -74,6 +72,7 @@ import {
isRepositorySelectionComplete,
toRepositorySelection,
} from "@/features/tasks/utils/repositorySelection";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { logger } from "@/lib/logger";
import { toRgba, useThemeColors } from "@/lib/theme";
@@ -110,9 +109,9 @@ export default function NewTaskScreen() {
}>();
const router = useRouter();
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
+ const { insets, bottom } = useScreenInsets();
const keyboard = useReanimatedKeyboardAnimation();
- const restingBottom = insets.bottom + 12;
+ const restingBottom = bottom("compact");
const {
error,
hasGithubIntegration,
diff --git a/apps/mobile/src/components/SheetContainer.tsx b/apps/mobile/src/components/SheetContainer.tsx
new file mode 100644
index 000000000..27a830130
--- /dev/null
+++ b/apps/mobile/src/components/SheetContainer.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from "react";
+import { Modal, Pressable, View } from "react-native";
+import {
+ type BottomGapVariant,
+ useScreenInsets,
+} from "@/hooks/useScreenInsets";
+
+const SHEET_SHADOW = {
+ shadowColor: "#000",
+ shadowOpacity: 0.15,
+ shadowRadius: 20,
+ shadowOffset: { width: 0, height: -4 },
+ elevation: 12,
+} as const;
+
+interface SheetContainerProps {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ /** Bottom padding gap above the safe-area inset. Defaults to "compact". */
+ bottomGap?: BottomGapVariant;
+ /** Extra classes for the sheet panel. */
+ className?: string;
+}
+
+/**
+ * Bottom-sheet shell: a dimmed backdrop, a panel pinned to the bottom edge with
+ * the standard rounded top, border, shadow, drag handle, and safe-area-aware
+ * bottom padding. Tapping the backdrop closes; taps inside the panel don't.
+ *
+ * Use this instead of re-implementing the `mt-auto rounded-t-2xl …` markup so
+ * every sheet shares one shape and one inset policy.
+ */
+export function SheetContainer({
+ open,
+ onClose,
+ children,
+ bottomGap = "compact",
+ className = "",
+}: SheetContainerProps) {
+ const { bottom } = useScreenInsets();
+
+ return (
+
+
+ {}}
+ className={`mt-auto rounded-t-2xl border-gray-6 border-t bg-background ${className}`}
+ style={{ paddingBottom: bottom(bottomGap), ...SHEET_SHADOW }}
+ >
+ {/* Drag handle */}
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
index 648f1b90c..6ca8ec8ff 100644
--- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
@@ -12,7 +12,7 @@ import {
TextInput,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import {
DISMISSAL_REASON_OPTIONS,
@@ -35,7 +35,7 @@ export function DismissReportSheet({
onClose,
onDismissed,
}: DismissReportSheetProps) {
- const insets = useSafeAreaInsets();
+ const { insets, bottom, contentTop } = useScreenInsets();
const themeColors = useThemeColors();
const [reason, setReason] = useState(null);
const [note, setNote] = useState("");
@@ -83,7 +83,7 @@ export function DismissReportSheet({
>
{/* Header */}
@@ -163,7 +163,7 @@ export function DismissReportSheet({
{/* Sticky submit */}
{/* Header */}
@@ -145,7 +145,7 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) {
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
- paddingBottom: insets.bottom + 40,
+ paddingBottom: bottom("roomy"),
}}
>
{/* Sort */}
diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx
index 927546241..4a5c14c2e 100644
--- a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx
+++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx
@@ -9,8 +9,8 @@ import {
ScrollView,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUserQuery } from "@/features/auth";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports";
import { useInboxFilterStore } from "../stores/inboxFilterStore";
@@ -67,7 +67,7 @@ export function ReviewerFilterSheet({
visible,
onClose,
}: ReviewerFilterSheetProps) {
- const insets = useSafeAreaInsets();
+ const { bottom, contentTop } = useScreenInsets();
const themeColors = useThemeColors();
const { data: currentUser } = useUserQuery();
const { data: available, isLoading } = useAvailableSuggestedReviewers();
@@ -98,7 +98,7 @@ export function ReviewerFilterSheet({
>
{/* Header */}
@@ -132,7 +132,7 @@ export function ReviewerFilterSheet({
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 12,
- paddingBottom: insets.bottom + 40,
+ paddingBottom: bottom("roomy"),
}}
>
{options.map((reviewer, index) => {
diff --git a/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx
index 3c5afb5b2..ce73885f6 100644
--- a/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx
+++ b/apps/mobile/src/features/tasks/components/FloatingNewAutomationButton.tsx
@@ -1,7 +1,7 @@
import { Plus } from "phosphor-react-native";
import { Pressable } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/text";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
interface FloatingNewAutomationButtonProps {
@@ -15,7 +15,7 @@ interface FloatingNewAutomationButtonProps {
export function FloatingNewAutomationButton({
onPress,
}: FloatingNewAutomationButtonProps) {
- const insets = useSafeAreaInsets();
+ const { fabBottom } = useScreenInsets();
const themeColors = useThemeColors();
return (
@@ -26,7 +26,7 @@ export function FloatingNewAutomationButton({
accessibilityRole="button"
className="absolute right-5 z-10 h-14 flex-row items-center justify-center gap-2 rounded-full bg-accent-9 pr-5 pl-4 active:opacity-85"
style={{
- bottom: insets.bottom + 20,
+ bottom: fabBottom(),
shadowColor: "#000",
shadowOpacity: 0.22,
shadowRadius: 12,
diff --git a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx
index a4b40ac97..8907c3d15 100644
--- a/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx
+++ b/apps/mobile/src/features/tasks/components/FloatingNewTaskButton.tsx
@@ -1,7 +1,7 @@
import { Plus } from "phosphor-react-native";
import { Pressable } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/text";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
interface FloatingNewTaskButtonProps {
@@ -13,7 +13,7 @@ interface FloatingNewTaskButtonProps {
* on phones of any size and respects the home indicator inset.
*/
export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) {
- const insets = useSafeAreaInsets();
+ const { fabBottom } = useScreenInsets();
const themeColors = useThemeColors();
return (
@@ -24,7 +24,7 @@ export function FloatingNewTaskButton({ onPress }: FloatingNewTaskButtonProps) {
accessibilityRole="button"
className="absolute right-5 z-10 h-14 flex-row items-center justify-center gap-2 rounded-full bg-accent-9 pr-5 pl-4 active:opacity-85"
style={{
- bottom: insets.bottom + 20,
+ bottom: fabBottom(),
shadowColor: "#000",
shadowOpacity: 0.22,
shadowRadius: 12,
diff --git a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx
index 81c0d8bde..97b87f447 100644
--- a/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx
+++ b/apps/mobile/src/features/tasks/components/TaskFilterMenu.tsx
@@ -2,8 +2,8 @@ import { Text } from "@components/text";
import { Check, FunnelSimple } from "phosphor-react-native";
import { useState } from "react";
import { Modal, Pressable, ScrollView, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUserQuery } from "@/features/auth";
+import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import {
type OrganizeMode,
@@ -44,7 +44,7 @@ function OptionRow({ label, selected, onPress }: OptionRowProps) {
}
export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) {
- const insets = useSafeAreaInsets();
+ const { bottom, contentTop } = useScreenInsets();
const organizeMode = useTaskStore((s) => s.organizeMode);
const setOrganizeMode = useTaskStore((s) => s.setOrganizeMode);
const sortMode = useTaskStore((s) => s.sortMode);
@@ -70,7 +70,7 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) {
>
{/* Header */}
@@ -88,7 +88,7 @@ export function TaskFilterMenu({ open, onClose }: TaskFilterMenuProps) {
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
- paddingBottom: insets.bottom + 40,
+ paddingBottom: bottom("roomy"),
}}
>
{/* Organize */}
diff --git a/apps/mobile/src/features/tasks/composer/SelectSheet.tsx b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx
index a1932f7a6..2fb6daf18 100644
--- a/apps/mobile/src/features/tasks/composer/SelectSheet.tsx
+++ b/apps/mobile/src/features/tasks/composer/SelectSheet.tsx
@@ -1,8 +1,8 @@
import { Text } from "@components/text";
import { Check } from "phosphor-react-native";
import type { ReactNode } from "react";
-import { Modal, Pressable, ScrollView, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Pressable, ScrollView, View } from "react-native";
+import { SheetContainer } from "@/components/SheetContainer";
import { useThemeColors } from "@/lib/theme";
export interface SelectOption {
@@ -31,84 +31,51 @@ export function SelectSheet({
onClose,
}: SelectSheetProps) {
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
return (
-
-
- {}}
- className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background"
- style={{
- paddingBottom: insets.bottom + 12,
- shadowColor: "#000",
- shadowOpacity: 0.15,
- shadowRadius: 20,
- shadowOffset: { width: 0, height: -4 },
- elevation: 12,
- }}
- >
- {/* Drag handle */}
-
-
-
+
+
+ {title}
+
-
-
- {title}
-
-
-
-
- {options.map((option) => {
- const selected = option.value === value;
- return (
- {
- if (option.disabled) return;
- onChange(option.value);
- onClose();
- }}
- disabled={option.disabled}
- className={`flex-row items-center gap-3 px-4 py-3 ${
- option.disabled ? "opacity-40" : "active:bg-gray-2"
- }`}
- >
- {option.icon ? (
-
- {option.icon}
-
- ) : null}
-
-
- {option.label}
-
- {option.description ? (
-
- {option.description}
-
- ) : null}
-
- {selected ? (
-
- ) : null}
-
- );
- })}
-
-
-
-
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ return (
+ {
+ if (option.disabled) return;
+ onChange(option.value);
+ onClose();
+ }}
+ disabled={option.disabled}
+ className={`flex-row items-center gap-3 px-4 py-3 ${
+ option.disabled ? "opacity-40" : "active:bg-gray-2"
+ }`}
+ >
+ {option.icon ? (
+
+ {option.icon}
+
+ ) : null}
+
+
+ {option.label}
+
+ {option.description ? (
+
+ {option.description}
+
+ ) : null}
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+
+
);
}
diff --git a/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx
index e64a6b387..6bfb44bad 100644
--- a/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx
+++ b/apps/mobile/src/features/tasks/composer/attachments/AttachmentSheet.tsx
@@ -1,7 +1,7 @@
import { Text } from "@components/text";
import { Camera, FileText, Image as ImageIcon } from "phosphor-react-native";
-import { Modal, Pressable, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Pressable, View } from "react-native";
+import { SheetContainer } from "@/components/SheetContainer";
import { useThemeColors } from "@/lib/theme";
interface AttachmentSheetProps {
@@ -44,74 +44,44 @@ export function AttachmentSheet({
onPickDocument,
}: AttachmentSheetProps) {
const themeColors = useThemeColors();
- const insets = useSafeAreaInsets();
return (
-
-
- {}}
- className="mt-auto rounded-t-2xl border-gray-6 border-t bg-background"
- style={{
- paddingBottom: insets.bottom + 12,
- shadowColor: "#000",
- shadowOpacity: 0.15,
- shadowRadius: 20,
- shadowOffset: { width: 0, height: -4 },
- elevation: 12,
- }}
- >
-
-
-
-
-
-
- Add attachment
-
-
+
+
+
+ Add attachment
+
+
-
- }
- label="Photo library"
- description="Pick a photo to share with the agent"
- onPress={() => {
- onClose();
- onPickPhoto();
- }}
- />
-
- }
- label="Take photo"
- description="Capture a new photo from the camera"
- onPress={() => {
- onClose();
- onPickCamera();
- }}
- />
-
- }
- label="File"
- description="Attach a text or code file from your device"
- onPress={() => {
- onClose();
- onPickDocument();
- }}
- />
-
-
-
+
+ }
+ label="Photo library"
+ description="Pick a photo to share with the agent"
+ onPress={() => {
+ onClose();
+ onPickPhoto();
+ }}
+ />
+
}
+ label="Take photo"
+ description="Capture a new photo from the camera"
+ onPress={() => {
+ onClose();
+ onPickCamera();
+ }}
+ />
+
}
+ label="File"
+ description="Attach a text or code file from your device"
+ onPress={() => {
+ onClose();
+ onPickDocument();
+ }}
+ />
+
);
}
diff --git a/apps/mobile/src/hooks/useScreenInsets.ts b/apps/mobile/src/hooks/useScreenInsets.ts
new file mode 100644
index 000000000..081fabf6b
--- /dev/null
+++ b/apps/mobile/src/hooks/useScreenInsets.ts
@@ -0,0 +1,61 @@
+import { useMemo } from "react";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+/**
+ * Single source of truth for screen spacing policy.
+ *
+ * The device safe-area insets (notch, home indicator, Android soft buttons)
+ * come from `useSafeAreaInsets()` and vary per device — we never hardcode
+ * those. What this hook centralizes is the *gap we add on top of the inset*,
+ * which was previously hand-written per screen with a scatter of magic numbers
+ * (12 / 16 / 20 / 24 / 32 / 40 / 50). Those are rationalized into one small
+ * scale so spacing is consistent across surfaces and tunable in one place.
+ */
+
+/**
+ * Standard content gaps layered ON TOP of the bottom safe-area inset.
+ * Pick by intent, not by pixel value.
+ */
+export const BOTTOM_GAP = {
+ /** Bottom sheets, sticky footers, tight composers. */
+ compact: 12,
+ /** Scrollable form / list content in modals and detail screens. */
+ default: 24,
+ /** Filter sheets and sections that want extra breathing room. */
+ roomy: 40,
+} as const;
+
+/** Standard gap above the top safe-area inset for page-sheet content. */
+const TOP_GAP = 8;
+
+/** Bottom-right floating action buttons sit this far above the inset. */
+const FAB_GAP = 20;
+
+/**
+ * The chat composer keeps at least this much bottom space when the keyboard
+ * is closed, even on devices that report a zero bottom inset (e.g. older
+ * Android with hardware buttons).
+ */
+export const COMPOSER_MIN_BOTTOM = 50;
+
+export type BottomGapVariant = keyof typeof BOTTOM_GAP;
+
+export function useScreenInsets() {
+ const insets = useSafeAreaInsets();
+
+ return useMemo(() => {
+ return {
+ /** Raw device insets, for cases the helpers below don't cover. */
+ insets,
+ /** Bottom padding = device inset + the standard gap for this surface. */
+ bottom: (variant: BottomGapVariant = "default") =>
+ insets.bottom + BOTTOM_GAP[variant],
+ /** Top padding for page-sheet content (inset + standard top gap). */
+ contentTop: () => insets.top + TOP_GAP,
+ /** Bottom offset for a floating action button. */
+ fabBottom: () => insets.bottom + FAB_GAP,
+ /** Composer bottom margin floor (never smaller than the min). */
+ composerBottom: () => Math.max(insets.bottom, COMPOSER_MIN_BOTTOM),
+ };
+ }, [insets]);
+}