From 97f7c07a1514d5f57a8165c1150b6e78c3f209bd Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Sun, 7 Jun 2026 07:10:29 +0000 Subject: [PATCH 1/2] fix: keep iOS UI inside the visual viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a few mobile/iOS layout problems visible on iPhone Safari: - Status bar's right-most buttons (session bucket flag, settings gear) could be pushed off-screen on narrow viewports because the cwd path refused to shrink enough. Hide `.statusPath` on `max-width: 700px`, cap the title width, and add `overflow: hidden` to `.statusBar` as a defensive guard so a stubborn child can't punt the right side of the header off-screen. - Composer footer (and the send button in particular) could be clipped at the right edge on iOS. This happens when iOS Safari programmatically scrolls the document while focusing the textarea, even though body has `overflow: hidden`. Snap any stray window scroll back to (0,0) inside `syncAppHeight`, and constrain the composer / footer to `calc(100vw - 20px)` on mobile so it physically can't overflow. - Add `padding-top: max(10px, env(safe-area-inset-top))` to the mobile `.app` so the status bar is not flush with the iOS notch / dynamic island in standalone (Add to Home Screen) mode. Apply the same safe- area treatment to the expanded composer's `inset`. - Use `100dvh` for `--app-height` where supported, with the existing JS visualViewport sync still acting as the source of truth at runtime. This avoids a brief mis-sized layout on first paint before the JS height sync runs. - Add `overscroll-behavior: none` to html/body so iOS rubber-band scrolling doesn't reveal blank chrome behind the app. No tests added — this is purely CSS / layout glue. `npm run typecheck`, `npm run test:unit`, and `npm run build` all pass. Signed-off-by: Megha Goyal --- src/app/elements.ts | 8 ++++++++ src/styles/base.css | 7 +++++++ src/styles/responsive.css | 28 ++++++++++++++++++++++++++++ src/styles/statusBar.css | 3 +++ 4 files changed, 46 insertions(+) diff --git a/src/app/elements.ts b/src/app/elements.ts index c41eab3..71d8c4f 100644 --- a/src/app/elements.ts +++ b/src/app/elements.ts @@ -129,6 +129,14 @@ export function getAppElements(): AppElements { export function syncAppHeight() { const height = window.visualViewport?.height || window.innerHeight; document.documentElement.style.setProperty("--app-height", `${height}px`); + // iOS Safari can scroll the document body when focusing an input near the + // bottom of the viewport (keyboard appearance). With our fixed-height, + // overflow:hidden layout that just leaves part of the UI — e.g. the + // composer footer or send button — visually clipped at the edges of the + // visual viewport. Snap any stray scroll back to the top. + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } } export function initAppHeightSync() { diff --git a/src/styles/base.css b/src/styles/base.css index 8912e71..07c1c58 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -14,12 +14,19 @@ --app-height: 100vh; } +@supports (height: 100dvh) { + :root { --app-height: 100dvh; } +} + * { box-sizing: border-box; } html, body { width: 100%; max-width: 100%; height: var(--app-height); overflow: hidden; + /* iOS Safari sometimes scrolls the html element when an input is focused + even with body { overflow: hidden }. Lock scroll position explicitly. */ + overscroll-behavior: none; } body { margin: 0; diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 338c555..ae5683c 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -29,14 +29,42 @@ height: var(--app-height); min-height: 0; padding: 10px 0; + padding-top: max(10px, env(safe-area-inset-top)); padding-bottom: max(10px, env(safe-area-inset-bottom)); } .composer { margin: 0 10px; margin-bottom: max(0px, env(safe-area-inset-bottom)); + /* Defensive: never let the composer (or its footer) overflow the + viewport horizontally on iOS, where keyboard / focus-scroll quirks + can otherwise clip the send button at the right edge. */ + max-width: calc(100vw - 20px); + min-width: 0; + } + .composerFooter { + min-width: 0; + max-width: 100%; + } + /* Expanded composer needs to respect safe-area insets on iOS too. */ + .composer.expanded { + inset: max(10px, env(safe-area-inset-top)) + max(10px, env(safe-area-inset-right)) + max(10px, env(safe-area-inset-bottom)) + max(10px, env(safe-area-inset-left)); } .message.user { margin: 12px 10px 12px 0; } + /* On narrow phone screens the cwd is too long and pushes the right-side + status-bar buttons (bucket, settings) off-screen. Hide it on mobile; + the working directory is still surfaced via the empty-state chooser + and the session list. */ + .statusPath { + display: none; + } + .statusTitle { + max-width: min(60vw, 240px); + } + .contextMeterLabel { top: -13px; right: 0; diff --git a/src/styles/statusBar.css b/src/styles/statusBar.css index c215e54..b92cdcf 100644 --- a/src/styles/statusBar.css +++ b/src/styles/statusBar.css @@ -7,6 +7,9 @@ padding: 0 8px; height: 24px; flex-shrink: 0; + /* Prevent right-side buttons (settings, bucket, …) from being pushed + off-screen on narrow viewports if a child refuses to shrink. */ + overflow: hidden; } .statusBar .iconButton { flex: 0 0 auto; From 4fcf435cee685887244ac6063d25ee4064832197 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Wed, 10 Jun 2026 18:25:20 +0000 Subject: [PATCH 2/2] fix: prevent iOS Safari auto-zoom and stray document scroll on the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to bfaa2b1 ("fix: keep iOS UI inside the visual viewport"): the previous round addressed layout overflow and visual-viewport sync, but on real iPhones (observed iPhone 16 Pro Max / iOS 17 Safari) the composer was still being clipped at the right edge with the send button half cut off, and the session bar tabs were overflowing horizontally. Two iOS-specific behaviours were the actual root cause: 1. iOS Safari auto-zooms the page on input focus whenever the focused input/textarea has font-size < 16px. The composer textarea inherits the body's 15px font, so every tap on the composer triggered a small automatic zoom that pushed the send button off-screen and made the session-bar tabs wider than the visual viewport. The user-visible symptom was "the UI is a little zoomed in". Fix: at @media (max-width: 700px), pin every text input (textarea / input[type=text|search|...] / bare input) to font-size: 16px. iOS only auto-zooms below 16px, so this fully disables the behaviour without any JS or zoom-disabling viewport meta-tag (which would harm accessibility / WCAG 1.4.4). 2. iOS Safari can still scroll the document body programmatically to keep the caret of a focused textarea in view, even with `html, body { overflow: hidden }`. That scroll fires window's `scroll` event, NOT visualViewport's, so the existing snap-back inside syncAppHeight (which only listens to visualViewport resize/scroll and window resize) never ran. Fix: - Add a passive window-level scroll listener that runs syncAppHeight, which already snaps `window.scrollX/Y` back to 0. - On focusin, schedule one extra rAF-deferred snap-back so the UI can never land in a half-scrolled state on the next frame. - Opt into `overflow: clip` on html/body via @supports for Safari 16+ / Chrome 90+ / Firefox 81+. `clip` is stricter than `hidden`: it forms no scroll container at all, so iOS's internal caret-tracking scrollIntoView has nothing to scroll. Verified locally: - npm run typecheck — clean - npm run build — succeeds - npm run test:unit — 55/55 pass Real-iPhone manual verification is the missing piece tracked in ashwin-pc/pi-web#23; this commit is the proposed fix for that issue. Signed-off-by: Megha Goyal --- src/app/elements.ts | 11 +++++++++++ src/styles/base.css | 8 ++++++++ src/styles/responsive.css | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/app/elements.ts b/src/app/elements.ts index 71d8c4f..5af9a45 100644 --- a/src/app/elements.ts +++ b/src/app/elements.ts @@ -144,4 +144,15 @@ export function initAppHeightSync() { window.addEventListener("resize", syncAppHeight); window.visualViewport?.addEventListener("resize", syncAppHeight); window.visualViewport?.addEventListener("scroll", syncAppHeight); + // iOS Safari ignores `html, body { overflow: hidden }` when the user types + // in a focused textarea: it scrolls the document horizontally to keep the + // caret in view, which clips the composer footer (send button) at the right + // edge. The visualViewport `scroll` event does NOT fire for document scroll, + // so we also need a window-level scroll listener to snap back. + window.addEventListener("scroll", syncAppHeight, { passive: true }); + // After focusing an input, iOS may scroll once more on the next frame; + // schedule a follow-up snap-back so the UI never lands offset. + document.addEventListener("focusin", () => { + requestAnimationFrame(syncAppHeight); + }, { passive: true }); } diff --git a/src/styles/base.css b/src/styles/base.css index 07c1c58..facf749 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -28,6 +28,14 @@ html, body { even with body { overflow: hidden }. Lock scroll position explicitly. */ overscroll-behavior: none; } +/* `overflow: clip` is stricter than `overflow: hidden` — it disables + programmatic scrolling entirely, which prevents iOS Safari from horizontally + scrolling the document while tracking the caret of a focused textarea. + Supported in Safari 16+, Chrome 90+, Firefox 81+. We keep `overflow: hidden` + above as a fallback for older engines. */ +@supports (overflow: clip) { + html, body { overflow: clip; } +} body { margin: 0; min-height: var(--app-height); diff --git a/src/styles/responsive.css b/src/styles/responsive.css index ae5683c..b3c98bd 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -32,6 +32,22 @@ padding-top: max(10px, env(safe-area-inset-top)); padding-bottom: max(10px, env(safe-area-inset-bottom)); } + /* iOS Safari auto-zooms the page when the user focuses an input/textarea + whose font-size is < 16px. That zoom shifts the layout sideways, clips + the composer’s send button at the right edge, makes the session-bar + tabs overflow, and is what users experience as “the UI is a little + zoomed in”. Force 16px on every text input on mobile so iOS leaves the + page at the natural zoom level. */ + textarea, + input[type="text"], + input[type="search"], + input[type="email"], + input[type="url"], + input[type="tel"], + input[type="password"], + input:not([type]) { + font-size: 16px; + } .composer { margin: 0 10px; margin-bottom: max(0px, env(safe-area-inset-bottom));