diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx
index 338d9f7bf1..9eb2d0a5d7 100644
--- a/apps/web/src/components/ComposerPromptEditor.tsx
+++ b/apps/web/src/components/ComposerPromptEditor.tsx
@@ -59,6 +59,8 @@ import {
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
+import { useMediaQuery } from "~/hooks/useMediaQuery";
+import { usePreventIosInputZoom } from "~/hooks/usePreventIosInputZoom";
import {
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
type TerminalContextDraft,
@@ -893,6 +895,13 @@ function ComposerPromptEditorInner({
}: ComposerPromptEditorInnerProps) {
const [editor] = useLexicalComposerContext();
const onChangeRef = useRef(onChange);
+ const isCoarsePointer = useMediaQuery({ pointer: "coarse" });
+ const composerFontSizePx = isCoarsePointer ? 16 : 14;
+ const {
+ onBlur: handleEditorBlur,
+ onFocus: handleEditorFocus,
+ onTouchStartCapture: handleEditorTouchStartCapture,
+ } = usePreventIosInputZoom();
const initialCursor = clampCollapsedComposerCursor(value, cursor);
const terminalContextsSignature = terminalContextSignature(terminalContexts);
const terminalContextsSignatureRef = useRef(terminalContextsSignature);
@@ -1093,18 +1102,25 @@ function ComposerPromptEditorInner({
contentEditable={
}
+ style={{ fontSize: composerFontSizePx }}
+ onBlur={handleEditorBlur}
+ onFocus={handleEditorFocus}
onPaste={onPaste}
+ onTouchStartCapture={handleEditorTouchStartCapture}
/>
}
placeholder={
terminalContexts.length > 0 ? null : (
-
+
{placeholder}
)
diff --git a/apps/web/src/hooks/usePreventIosInputZoom.test.ts b/apps/web/src/hooks/usePreventIosInputZoom.test.ts
new file mode 100644
index 0000000000..dbcbadc706
--- /dev/null
+++ b/apps/web/src/hooks/usePreventIosInputZoom.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildInputZoomLockedViewportContent,
+ isIosInputZoomPlatform,
+} from "./usePreventIosInputZoom";
+
+describe("isIosInputZoomPlatform", () => {
+ it("detects iPhone user agents", () => {
+ expect(
+ isIosInputZoomPlatform({
+ maxTouchPoints: 5,
+ platform: "iPhone",
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15",
+ }),
+ ).toBe(true);
+ });
+
+ it("detects iPadOS devices that report as MacIntel", () => {
+ expect(
+ isIosInputZoomPlatform({
+ maxTouchPoints: 5,
+ platform: "MacIntel",
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
+ }),
+ ).toBe(true);
+ });
+
+ it("ignores desktop macOS", () => {
+ expect(
+ isIosInputZoomPlatform({
+ maxTouchPoints: 0,
+ platform: "MacIntel",
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("buildInputZoomLockedViewportContent", () => {
+ it("adds the focus zoom lock directives", () => {
+ expect(buildInputZoomLockedViewportContent("width=device-width, initial-scale=1.0")).toBe(
+ "width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no",
+ );
+ });
+
+ it("replaces existing maximum scale directives", () => {
+ expect(
+ buildInputZoomLockedViewportContent(
+ "width=device-width, initial-scale=1.0, maximum-scale=5, user-scalable=yes",
+ ),
+ ).toBe("width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no");
+ });
+});
diff --git a/apps/web/src/hooks/usePreventIosInputZoom.ts b/apps/web/src/hooks/usePreventIosInputZoom.ts
new file mode 100644
index 0000000000..a84a16fe45
--- /dev/null
+++ b/apps/web/src/hooks/usePreventIosInputZoom.ts
@@ -0,0 +1,126 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+
+const DEFAULT_VIEWPORT_CONTENT = "width=device-width, initial-scale=1.0";
+const IOS_USER_AGENT_PATTERN = /\b(iPad|iPhone|iPod)\b/i;
+const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]';
+
+export function isIosInputZoomPlatform(input: {
+ maxTouchPoints: number;
+ platform: string;
+ userAgent: string;
+}): boolean {
+ return (
+ IOS_USER_AGENT_PATTERN.test(input.userAgent) ||
+ (input.platform === "MacIntel" && input.maxTouchPoints > 1)
+ );
+}
+
+export function buildInputZoomLockedViewportContent(content: string): string {
+ const baseContent = content.trim() || DEFAULT_VIEWPORT_CONTENT;
+ const segments = baseContent
+ .split(",")
+ .map((segment) => segment.trim())
+ .filter((segment) => segment.length > 0);
+ const filteredSegments = segments.filter((segment) => {
+ const [key] = segment.split("=", 1);
+ const normalizedKey = key?.trim().toLowerCase();
+ return normalizedKey !== "maximum-scale" && normalizedKey !== "user-scalable";
+ });
+
+ filteredSegments.push("maximum-scale=1", "user-scalable=no");
+ return filteredSegments.join(", ");
+}
+
+export function usePreventIosInputZoom(): {
+ onBlur: () => void;
+ onFocus: () => void;
+ onTouchStartCapture: () => void;
+} {
+ const isEnabled = useMemo(() => {
+ if (typeof navigator === "undefined") {
+ return false;
+ }
+
+ return isIosInputZoomPlatform({
+ maxTouchPoints: navigator.maxTouchPoints,
+ platform: navigator.platform,
+ userAgent: navigator.userAgent,
+ });
+ }, []);
+ const focusedRef = useRef(false);
+ const lockStateRef = useRef<{
+ isLocked: boolean;
+ originalContent: string | null;
+ viewportMeta: HTMLMetaElement | null;
+ }>({
+ isLocked: false,
+ originalContent: null,
+ viewportMeta: null,
+ });
+
+ const restoreViewport = useCallback(() => {
+ const lockState = lockStateRef.current;
+ if (!lockState.isLocked) {
+ return;
+ }
+
+ if (lockState.viewportMeta && lockState.originalContent != null) {
+ lockState.viewportMeta.setAttribute("content", lockState.originalContent);
+ }
+
+ lockState.isLocked = false;
+ lockState.originalContent = null;
+ lockState.viewportMeta = null;
+ }, []);
+
+ const lockViewport = useCallback(() => {
+ if (!isEnabled || typeof document === "undefined") {
+ return;
+ }
+
+ const lockState = lockStateRef.current;
+ if (lockState.isLocked) {
+ return;
+ }
+
+ const viewportMeta = document.querySelector
(VIEWPORT_META_SELECTOR);
+ if (!viewportMeta) {
+ return;
+ }
+
+ lockState.isLocked = true;
+ lockState.originalContent = viewportMeta.getAttribute("content") ?? DEFAULT_VIEWPORT_CONTENT;
+ lockState.viewportMeta = viewportMeta;
+ viewportMeta.setAttribute(
+ "content",
+ buildInputZoomLockedViewportContent(lockState.originalContent),
+ );
+ }, [isEnabled]);
+
+ const handleTouchStartCapture = useCallback(() => {
+ lockViewport();
+ requestAnimationFrame(() => {
+ if (!focusedRef.current) {
+ restoreViewport();
+ }
+ });
+ }, [lockViewport, restoreViewport]);
+
+ const handleFocus = useCallback(() => {
+ focusedRef.current = true;
+ lockViewport();
+ }, [lockViewport]);
+
+ const handleBlur = useCallback(() => {
+ focusedRef.current = false;
+ restoreViewport();
+ }, [restoreViewport]);
+
+ useEffect(() => restoreViewport, [restoreViewport]);
+
+ return {
+ onBlur: handleBlur,
+ onFocus: handleFocus,
+ onTouchStartCapture: handleTouchStartCapture,
+ };
+}