From ca6ffe9196ba8c567dcbf87d1655f4e9f94caa66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:01:06 +0000 Subject: [PATCH 1/3] fix(web): iOS Safari keyboard no longer hides the composer or scrolls the header away iOS and iPadOS Safari ignore interactive-widget=resizes-content: the keyboard shrinks only the visual viewport, so the h-dvh/h-svh shell kept its full height, the composer sat behind the keyboard and Safari's pan scrolled the header off-screen. While the visual viewport is more than the keyboard threshold shorter than the layout viewport (and not pinch-zoomed), pin the app shell to the visible area via --keyboard-viewport-height/top and data-keyboard-viewport on . Browsers that already resize for the keyboard never lose height and are unaffected. The portaled composer command menu now anchors to the layout viewport and re-measures on visualViewport resize/scroll so it follows the pinned shell. Fixes pingdotgg/t3code#19512 AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/AppSidebarLayout.tsx | 4 +- .../chat/ComposerCommandMenuLayer.tsx | 8 +- apps/web/src/hooks/useSoftwareKeyboardOpen.ts | 9 +- apps/web/src/index.css | 25 ++++++ apps/web/src/lib/softwareKeyboard.test.ts | 82 ++++++++++++++++++- apps/web/src/lib/softwareKeyboard.ts | 59 +++++++++++++ 6 files changed, 182 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index e4f3d690113e..477ae5da53b4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -32,6 +32,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode } from "../hooks/useSettings"; +import { usePinShellToKeyboardViewport } from "../hooks/useSoftwareKeyboardOpen"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -421,6 +422,7 @@ export function AppSidebarLayout({ })); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); + usePinShellToKeyboardViewport(); const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; @@ -457,7 +459,7 @@ export function AppSidebarLayout({ return ( pinned to the visual viewport via its listeners while the shell is mounted + useEffect(() => pinShellToKeyboardViewport(window, isElectron), []); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 654ef108e8e7..ce3b7730680c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2596,3 +2596,28 @@ code { outline: 2px solid var(--ring); outline-offset: -2px; } + +/* iPadOS and iOS Safari ignore interactive-widget=resizes-content: the keyboard shrinks only the + visual viewport and Safari pans the page to the focused field. While it is open the shell is + pinned to the visible area instead (pinShellToKeyboardViewport). Unlayered so these rules + outrank the routes' h-dvh/h-svh and the sidebar container's h-svh utilities. */ +html[data-keyboard-viewport], +html[data-keyboard-viewport] body { + overflow: hidden; +} + +html[data-keyboard-viewport] [data-slot="sidebar-wrapper"] { + position: fixed; + inset-inline: 0; + top: var(--keyboard-viewport-top); + /* Fixed descendants, the desktop sidebar and its toggle, position against the shell. */ + contain: layout; +} + +html[data-keyboard-viewport] [data-slot="sidebar-inset"] { + height: 100%; +} + +html[data-keyboard-viewport] [data-slot="sidebar-container"] { + height: auto; +} diff --git a/apps/web/src/lib/softwareKeyboard.test.ts b/apps/web/src/lib/softwareKeyboard.test.ts index d8d845102b48..0713b469881b 100644 --- a/apps/web/src/lib/softwareKeyboard.test.ts +++ b/apps/web/src/lib/softwareKeyboard.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { createSoftwareKeyboardStore } from "./softwareKeyboard"; +import { + createSoftwareKeyboardStore, + measureKeyboardViewport, + pinShellToKeyboardViewport, +} from "./softwareKeyboard"; function fixture(options: { native?: boolean; overlay?: boolean; desktop?: boolean } = {}) { const viewport = Object.assign(new EventTarget(), { width: 390, height: 844, scale: 1 }); @@ -134,3 +138,79 @@ describe("software keyboard detection", () => { unsubscribe(); }); }); + +describe("keyboard viewport measurement", () => { + // Measured on an iPad Pro 13-inch in portrait, where innerHeight follows the keyboard. + it("measures the visible area Safari panned to under a docked keyboard", () => { + expect(measureKeyboardViewport(1244, { height: 841, offsetTop: 403, scale: 1 })).toEqual({ + height: 841, + offsetTop: 403, + }); + }); + + it("leaves the layout alone without a keyboard or with only the shortcut bar", () => { + expect(measureKeyboardViewport(1244, { height: 1244, offsetTop: 0, scale: 1 })).toBeNull(); + expect(measureKeyboardViewport(1244, { height: 1176, offsetTop: 0, scale: 1 })).toBeNull(); + }); + + it("leaves a keyboard that already resized the layout viewport to the browser", () => { + expect(measureKeyboardViewport(444, { height: 444, offsetTop: 0, scale: 1 })).toBeNull(); + }); + + it("stands down while pinch-zoomed", () => { + expect(measureKeyboardViewport(1244, { height: 400, offsetTop: 300, scale: 2 })).toBeNull(); + }); +}); + +describe("pinning the shell to the keyboard viewport", () => { + function pinFixture(desktop = false) { + const viewport = Object.assign(new EventTarget(), { height: 1244, offsetTop: 0, scale: 1 }); + const properties = new Map(); + const setProperty = vi.fn((key: string, value: string) => properties.set(key, value)); + const root = { + clientHeight: 1244, + dataset: {} as DOMStringMap, + style: { setProperty, removeProperty: (key: string) => properties.delete(key) }, + }; + const stop = pinShellToKeyboardViewport( + { visualViewport: viewport, document: { documentElement: root } } as unknown as Window, + desktop, + ); + const move = (event: "resize" | "scroll", height: number, offsetTop: number) => { + Object.assign(viewport, { height, offsetTop }); + viewport.dispatchEvent(new Event(event)); + }; + return { root, properties, setProperty, stop, move }; + } + + it("follows the keyboard and Safari's pan, then restores the layout when it closes", () => { + const { root, properties, setProperty, stop, move } = pinFixture(); + expect(root.dataset.keyboardViewport).toBeUndefined(); + + move("resize", 841, 0); + move("scroll", 841, 403); + expect(root.dataset.keyboardViewport).toBe(""); + expect(Object.fromEntries(properties)).toEqual({ + "--keyboard-viewport-height": "841px", + "--keyboard-viewport-top": "403px", + }); + const writes = setProperty.mock.calls.length; + move("scroll", 841, 403); + expect(setProperty.mock.calls.length).toBe(writes); + + move("resize", 1244, 0); + expect(root.dataset.keyboardViewport).toBeUndefined(); + expect(properties.size).toBe(0); + + move("resize", 841, 403); + stop(); + expect(root.dataset.keyboardViewport).toBeUndefined(); + expect(properties.size).toBe(0); + }); + + it("never pins on desktop", () => { + const { root, move } = pinFixture(true); + move("resize", 841, 403); + expect(root.dataset.keyboardViewport).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/softwareKeyboard.ts b/apps/web/src/lib/softwareKeyboard.ts index e20d5fe00b4a..3b39e9e79f1d 100644 --- a/apps/web/src/lib/softwareKeyboard.ts +++ b/apps/web/src/lib/softwareKeyboard.ts @@ -81,3 +81,62 @@ export function createSoftwareKeyboardStore(win: KeyboardWindow | undefined, isD return { getSnapshot, subscribe }; } + +type ViewportGeometry = Pick; + +/** + * The visible area while an on-screen keyboard overlays the layout viewport, or null when the + * layout already fits it. Safari ignores `interactive-widget=resizes-content`, so only there does + * the visual viewport lose height; `innerHeight` follows the keyboard on iPadOS, so callers pass + * a stable layout height such as `documentElement.clientHeight`. + */ +export function measureKeyboardViewport(layoutHeight: number, viewport: ViewportGeometry) { + // Pinch zoom shrinks the visual viewport too, and the page must stay pannable then. + if (viewport.scale > 1.01) return null; + const height = Math.round(viewport.height); + if (layoutHeight - height <= KEYBOARD_THRESHOLD) return null; + return { height, offsetTop: Math.max(0, Math.round(viewport.offsetTop)) }; +} + +type PinWindow = Pick & { + readonly document: { readonly documentElement: HTMLElement }; +}; + +/** Pins the app shell to the visible area while a keyboard overlays it; see index.css. */ +export function pinShellToKeyboardViewport(win: PinWindow, isDesktop: boolean): () => void { + const viewport = win.visualViewport; + if (isDesktop || !viewport) return () => {}; + const root = win.document.documentElement; + let pinned = ""; + + const unpin = () => { + pinned = ""; + delete root.dataset.keyboardViewport; + root.style.removeProperty("--keyboard-viewport-height"); + root.style.removeProperty("--keyboard-viewport-top"); + }; + + const update = () => { + const area = measureKeyboardViewport(root.clientHeight, viewport); + const next = area ? `${area.height}:${area.offsetTop}` : ""; + if (next === pinned) return; + if (!area) { + unpin(); + return; + } + pinned = next; + root.style.setProperty("--keyboard-viewport-height", `${area.height}px`); + root.style.setProperty("--keyboard-viewport-top", `${area.offsetTop}px`); + root.dataset.keyboardViewport = ""; + }; + + update(); + // Safari pans the visual viewport with scroll events alone while it reveals a focused field. + viewport.addEventListener("resize", update, { passive: true }); + viewport.addEventListener("scroll", update, { passive: true }); + return () => { + viewport.removeEventListener("resize", update); + viewport.removeEventListener("scroll", update); + unpin(); + }; +} From 5706cfe177095c2767e9b039090ab769842e142b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:21:37 +0000 Subject: [PATCH 2/3] fix(web): composer menu height counts from the visible top when Safari pans Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ComposerCommandMenuLayer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx index f821c4e97e92..f6e51393e396 100644 --- a/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx @@ -47,11 +47,13 @@ export function ComposerCommandMenuLayer(props: { return; } const composerOverlap = rootFontSizePx + 1; + // Rects are in layout-viewport coordinates; iOS keeps that viewport full height under the + // keyboard and pans the visible area down it, so the space above starts at offsetTop. + const visibleTop = Math.max(0, window.visualViewport?.offsetTop ?? 0); const next = { - // `bottom` is measured from the layout viewport, which iOS keeps full height under the keyboard. bottom: document.documentElement.clientHeight - rect.top - composerOverlap, left: rect.left + drawerInset, - maxHeight: Math.max(96, rect.top - 24 + composerOverlap), + maxHeight: Math.max(96, rect.top - visibleTop - 24 + composerOverlap), width, }; setPosition((current) => From 92ffa4b4ff4dea3c6deecdc64d845f2d37b1f63b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 17:13:11 +0000 Subject: [PATCH 3/3] fix(web): composer menu renders inside the shell pinned over Safari's keyboard Co-Authored-By: Claude Opus 5.5 --- .../chat/ComposerCommandMenuLayer.tsx | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx index f6e51393e396..4bc48a3c2fd9 100644 --- a/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenuLayer.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { observeResize } from "~/lib/observeResize"; type ComposerCommandMenuPosition = { + container: HTMLElement; bottom: number; left: number; maxHeight: number; @@ -14,7 +15,11 @@ function composerCommandMenuPositionsEqual( b: ComposerCommandMenuPosition, ): boolean { return ( - a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width + a.container === b.container && + a.bottom === b.bottom && + a.left === b.left && + a.maxHeight === b.maxHeight && + a.width === b.width ); } @@ -47,13 +52,22 @@ export function ComposerCommandMenuLayer(props: { return; } const composerOverlap = rootFontSizePx + 1; - // Rects are in layout-viewport coordinates; iOS keeps that viewport full height under the - // keyboard and pans the visible area down it, so the space above starts at offsetTop. - const visibleTop = Math.max(0, window.visualViewport?.offsetTop ?? 0); + // While the shell is pinned over Safari's keyboard it is the containing block for fixed + // descendants (see index.css), so the menu renders inside it and is placed relative to it. + const shell = + document.documentElement.dataset.keyboardViewport === undefined + ? null + : anchor.closest('[data-slot="sidebar-wrapper"]'); + const frame = shell?.getBoundingClientRect() ?? { + top: 0, + left: 0, + bottom: document.documentElement.clientHeight, + }; const next = { - bottom: document.documentElement.clientHeight - rect.top - composerOverlap, - left: rect.left + drawerInset, - maxHeight: Math.max(96, rect.top - visibleTop - 24 + composerOverlap), + container: shell ?? document.body, + bottom: frame.bottom - rect.top - composerOverlap, + left: rect.left - frame.left + drawerInset, + maxHeight: Math.max(96, rect.top - frame.top - 24 + composerOverlap), width, }; setPosition((current) => @@ -64,7 +78,7 @@ export function ComposerCommandMenuLayer(props: { updatePosition(); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); - // Safari moves the pinned shell with the visual viewport without firing window events. + // The shell is pinned and unpinned on visual viewport changes, which fire no window events. window.visualViewport?.addEventListener("resize", updatePosition); window.visualViewport?.addEventListener("scroll", updatePosition); @@ -98,6 +112,6 @@ export function ComposerCommandMenuLayer(props: { > {props.children} , - document.body, + position.container, ); }