diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 225496bb7a407..a9efe5f643214 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -22,7 +22,7 @@ import { pipe } from "effect/Function";
import { RegistryContext } from "@effect/atom-react";
import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages";
import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
-import * as Dialog from "./components/ui/Dialog";
+import { DialogHost } from "./components/ui/DialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator";
@@ -240,7 +240,7 @@ function AppContent() {
theme={navigationTheme}
onStateChange={handleNavigationStateChange}
/>
-
+
diff --git a/apps/mobile/src/components/ui/Dialog.tsx b/apps/mobile/src/components/ui/Dialog.tsx
index afef71264cc6d..778d5d7464137 100644
--- a/apps/mobile/src/components/ui/Dialog.tsx
+++ b/apps/mobile/src/components/ui/Dialog.tsx
@@ -18,19 +18,18 @@ export function confirm(request: ConfirmDialogRequest): void {
]);
}
+// The most recently mounted host presents, so a sheet's host wins while the sheet is open.
+const promptPresenters: Array<(request: TextInputDialogRequest) => void> = [];
+
+// UIAlertController's text field lays out past the alert's edge on iOS 26 (Liquid Glass),
+// so prompts render in-app through DialogHost instead of through Alert.prompt.
export function prompt(request: TextInputDialogRequest): void {
- Alert.prompt(
- request.title,
- request.message,
- [
- { text: request.cancelText ?? "Cancel", style: "cancel", onPress: request.onCancel },
- { text: request.confirmText, onPress: (value) => request.onConfirm(value ?? "") },
- ],
- "plain-text",
- request.initialValue,
- );
+ promptPresenters.at(-1)?.(request);
}
-export function Host() {
- return null;
+export function registerPromptPresenter(present: (request: TextInputDialogRequest) => void) {
+ promptPresenters.push(present);
+ return () => {
+ promptPresenters.splice(promptPresenters.indexOf(present), 1);
+ };
}
diff --git a/apps/mobile/src/components/ui/DialogHost.android.tsx b/apps/mobile/src/components/ui/DialogHost.android.tsx
new file mode 100644
index 0000000000000..224010985a672
--- /dev/null
+++ b/apps/mobile/src/components/ui/DialogHost.android.tsx
@@ -0,0 +1,6 @@
+export { Host as DialogHost } from "./Dialog.android";
+
+/** Compose dialogs open in their own window, so sheets need no extra host on Android. */
+export function DialogSheetHost() {
+ return null;
+}
diff --git a/apps/mobile/src/components/ui/DialogHost.test.tsx b/apps/mobile/src/components/ui/DialogHost.test.tsx
new file mode 100644
index 0000000000000..7c1ac19255116
--- /dev/null
+++ b/apps/mobile/src/components/ui/DialogHost.test.tsx
@@ -0,0 +1,153 @@
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, it, vi } from "vite-plus/test";
+
+vi.mock("react-native", () => {
+ const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
;
+ return {
+ Alert: { alert: vi.fn() },
+ KeyboardAvoidingView: Slot,
+ Modal: (props: {
+ readonly children?: ReactNode;
+ readonly visible: boolean;
+ readonly onDismiss?: () => void;
+ readonly onRequestClose?: () => void;
+ }) => (
+
+ {props.visible ? props.children : null}
+
+
+
+ ),
+ ScrollView: Slot,
+ View: Slot,
+ useWindowDimensions: () => ({ width: 390, height: 844 }),
+ };
+});
+vi.mock("./AppText", () => ({
+ AppText: (props: { readonly children?: ReactNode }) => {props.children},
+ AppTextInput: (props: { readonly value: string; readonly onChangeText: (v: string) => void }) => (
+ props.onChangeText(event.target.value)} />
+ ),
+}));
+vi.mock("./Button", () => ({
+ Button: (props: { readonly label: string; readonly onPress: () => void }) => (
+
+ ),
+}));
+vi.mock("./GlassSurface", () => ({
+ GlassSurface: (props: { readonly children?: ReactNode }) => {props.children}
,
+}));
+vi.mock("../../features/settings/appearance/AppearancePreferencesProvider", () => ({
+ useAppearancePreferences: () => ({ themeVariables: {} }),
+}));
+
+import * as Dialog from "./Dialog";
+import { DialogHost, DialogSheetHost } from "./DialogHost";
+
+const roots: Root[] = [];
+
+function mountHost(host: () => ReactNode = () => ) {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ roots.push(root);
+ act(() => root.render(host()));
+ return { root, container };
+}
+
+function button(container: HTMLElement, label: string) {
+ return [...container.querySelectorAll("button")].find((node) => node.textContent === label)!;
+}
+
+function promptRename(onConfirm: (value: string) => void) {
+ act(() =>
+ Dialog.prompt({
+ title: "Rename thread",
+ initialValue: "old",
+ confirmText: "Rename",
+ onConfirm,
+ }),
+ );
+}
+
+afterEach(() => {
+ for (const root of roots.splice(0)) act(() => root.unmount());
+ document.body.innerHTML = "";
+});
+
+it("confirms the edited value exactly once after native dismissal", () => {
+ const { container } = mountHost();
+ const onConfirm = vi.fn();
+ promptRename(onConfirm);
+
+ const input = container.querySelector("input")!;
+ act(() => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "new");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const rename = button(container, "Rename");
+ act(() => {
+ rename.click();
+ rename.click();
+ });
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ const dismiss = button(container, "Dismiss modal");
+ act(() => {
+ dismiss.click();
+ dismiss.click();
+ });
+ expect(onConfirm).toHaveBeenCalledExactlyOnceWith("new");
+ expect(container.textContent).toBe("");
+});
+
+it("presents from a mounted sheet host, then falls back to the root host", () => {
+ const app = mountHost();
+ const sheet = mountHost(() => );
+
+ promptRename(vi.fn());
+ expect(sheet.container.textContent).toContain("Rename thread");
+ expect(app.container.textContent).toBe("");
+ act(() => button(sheet.container, "Cancel").click());
+
+ roots.splice(roots.indexOf(sheet.root), 1);
+ act(() => sheet.root.unmount());
+ promptRename(vi.fn());
+ expect(app.container.textContent).toContain("Rename thread");
+});
+
+it.each(["Cancel", "Request close"])(
+ "waits for dismissal before %s opens another dialog",
+ (action) => {
+ const { container } = mountHost();
+ const onCancel = vi.fn(() =>
+ Dialog.prompt({
+ title: "Second prompt",
+ initialValue: "",
+ confirmText: "Save",
+ onConfirm: vi.fn(),
+ }),
+ );
+ act(() =>
+ Dialog.prompt({
+ title: "First prompt",
+ initialValue: "",
+ confirmText: "Save",
+ onConfirm: vi.fn(),
+ onCancel,
+ }),
+ );
+ act(() => button(container, action).click());
+ expect(onCancel).not.toHaveBeenCalled();
+ const dismiss = button(container, "Dismiss modal");
+ act(() => {
+ dismiss.click();
+ dismiss.click();
+ });
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(container.textContent).toContain("Second prompt");
+ expect(container.textContent).not.toContain("First prompt");
+ },
+);
diff --git a/apps/mobile/src/components/ui/DialogHost.tsx b/apps/mobile/src/components/ui/DialogHost.tsx
new file mode 100644
index 0000000000000..2a22aa6811c6a
--- /dev/null
+++ b/apps/mobile/src/components/ui/DialogHost.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Modal,
+ ScrollView,
+ useWindowDimensions,
+ View,
+ type TextInputInstance,
+} from "react-native";
+
+import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
+import { AppText, AppTextInput } from "./AppText";
+import { Button } from "./Button";
+import { registerPromptPresenter, type TextInputDialogRequest } from "./Dialog";
+import { GlassSurface } from "./GlassSurface";
+
+export function DialogHost() {
+ const [presented, setPresented] = useState<
+ (TextInputDialogRequest & { readonly id: number }) | null
+ >(null);
+ const nextPresentationId = useRef(0);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register the native prompt presenter for this host's lifetime.
+ useEffect(() => {
+ const present = (request: TextInputDialogRequest) =>
+ setPresented({ ...request, id: ++nextPresentationId.current });
+ return registerPromptPresenter(present);
+ }, []);
+
+ return presented ? (
+ setPresented(null)} />
+ ) : null;
+}
+
+/**
+ * A React Native Modal presents from its nearest view controller, and the root one cannot present
+ * over a native sheet. Mount this inside sheets that open prompts.
+ */
+export const DialogSheetHost = DialogHost;
+
+function PromptDialog(props: {
+ readonly request: TextInputDialogRequest;
+ readonly onClose: () => void;
+}) {
+ const { request } = props;
+ const { width, height, fontScale } = useWindowDimensions();
+ const stackActions = fontScale > 1.3;
+ const { themeVariables } = useAppearancePreferences();
+ const inputRef = useRef(null);
+ const pendingAction = useRef<(() => void) | null>(null);
+ const [visible, setVisible] = useState(true);
+ const [value, setValue] = useState(request.initialValue);
+ const settle = (run: () => void) => {
+ if (pendingAction.current) return;
+ pendingAction.current = run;
+ setVisible(false);
+ };
+ const dismissed = () => {
+ const run = pendingAction.current;
+ if (!run) return;
+ pendingAction.current = null;
+ props.onClose();
+ // Callbacks can present another native alert or sheet only after UIKit dismisses this modal.
+ run();
+ };
+ const cancel = () => settle(() => request.onCancel?.());
+ const submit = () => settle(() => request.onConfirm(value));
+
+ return (
+ inputRef.current?.focus()}
+ onRequestClose={cancel}
+ onDismiss={dismissed}
+ >
+
+
+
+
+ {request.title}
+
+ {request.message ? (
+
+ {request.message}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx
index ce17eb7afca12..1178edc843efb 100644
--- a/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadPullRequestsSheet.tsx
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader";
import { AppText as Text } from "../../components/ui/AppText";
+import { DialogSheetHost } from "../../components/ui/DialogHost";
import { ScreenContent } from "../../components/ui/ScreenContent";
import { SheetHeader } from "../../components/ui/SheetHeader";
import { Toolbar } from "../../components/ui/Toolbar";
@@ -104,6 +105,7 @@ export function ThreadPullRequestsSheet(
) : null}
{content}
+
);
}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 737e1aff90ada..e092be53a3491 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -1,4 +1,5 @@
import * as Dialog from "../../components/ui/Dialog";
+import { DialogSheetHost } from "../../components/ui/DialogHost";
import { useRememberThreadProject } from "./useRememberThreadProject";
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
@@ -1114,6 +1115,7 @@ export function FullscreenThreadRouteScreen(
route={props.route}
inspectorPresentation={{ onClose: () => navigation.goBack() }}
/>
+
);
}
diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
index ac60fb66f6aac..1a578bdcdde6f 100644
--- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
@@ -1,4 +1,5 @@
import * as Dialog from "../../../components/ui/Dialog";
+import { DialogSheetHost } from "../../../components/ui/DialogHost";
import { SheetHeader } from "../../../components/ui/SheetHeader";
import { useOpenPullRequest } from "../../../native/useOpenPullRequest";
import {
@@ -70,7 +71,12 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
if (isBlankLink) navigation.goBack();
}, [isBlankLink, navigation]);
if (isBlankLink) return null;
- return ;
+ return (
+ <>
+
+ {props.presentation !== "inspector" ? : null}
+ >
+ );
}
function GitOverviewSheetContent(props: GitOverviewSheetProps) {
diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts
index 6e24636671608..e2c5157809627 100644
--- a/apps/mobile/vite.config.ts
+++ b/apps/mobile/vite.config.ts
@@ -17,6 +17,7 @@ const reactTests = [
"src/features/pull-requests/usePullRequestDiff.test.tsx",
"src/components/ProjectFavicon.test.tsx",
"src/components/ui/Dialog.android.test.tsx",
+ "src/components/ui/DialogHost.test.tsx",
"src/features/pull-requests/usePullRequestList.test.tsx",
"src/features/files/ZipArchiveView.test.tsx",
"src/features/pull-requests/guide/useReviewGuide.test.tsx",