Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
} from "../panelAnimations";
import LegacyThreadSidebar from "./LegacySidebar";
import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
import { useIsPhoneViewport } from "../hooks/useMediaQuery";
import { useVisualViewportSync } from "../hooks/useVisualViewportSync";
import ThreadSidebar from "./Sidebar";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
Expand Down Expand Up @@ -236,6 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
// sidebar is active.
// Seeds server-side visited tracking from this browser's localStorage the
useThreadVisitedMigration();
useVisualViewportSync(useIsPhoneViewport());
const pathname = useLocation({ select: (location) => location.pathname });
const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
Expand Down Expand Up @@ -314,7 +317,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
return (
<PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
<SidebarProvider
className="h-dvh! min-h-0!"
// On phones the shell follows the visible area so the keyboard never pushes the header
// away or hides the composer. See useVisualViewportSync and its rules in index.css.
className="h-[var(--app-shell-height,100dvh)]! min-h-0!"
data-panel-animations={routePanelAnimationsActive ? "true" : "false"}
defaultOpen
style={sidebarProviderStyle}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11994,6 +11994,7 @@ export default function ChatView(props: ChatViewProps) {
</ComposerSurface.Shell>
<div
aria-hidden
data-composer-bottom-spacer
className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
>
{backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null}
{/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}
<div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">
<div className="relative top-[calc(var(--workspace-controls-top)-var(--workspace-controls-safe-top,env(safe-area-inset-top)))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">
<SidebarTrigger
// Over the stage artwork: the media viewer's control-on-imagery treatment.
variant={backdropVariant ? "media-navigation" : "ghost"}
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/hooks/useMediaQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { act, createElement } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { useIsPhoneViewport } from "./useMediaQuery";

describe("useIsPhoneViewport", () => {
let renderer: ReactTestRenderer | null;
let viewport: { width: number; height: number; coarse: boolean };
let orientation: (EventTarget & { type: string }) | undefined;
let queries: Array<EventTarget & { media: string; matches: boolean }>;

function evaluate(media: string): boolean {
if (media === "(orientation: portrait)") return viewport.height >= viewport.width;
return viewport.width <= 639 && viewport.coarse;
}

function Probe() {
return createElement("output", { value: String(useIsPhoneViewport()) });
}

function phone(): boolean {
return renderer?.root.findByType("output").props.value === "true";
}

async function render() {
await act(() => {
renderer = create(createElement(Probe));
});
}

async function rotate(width: number, height: number, type?: string) {
await act(() => {
Object.assign(viewport, { width, height });
for (const query of queries) {
query.matches = evaluate(query.media);
query.dispatchEvent(new Event("change"));
}
if (orientation && type) {
orientation.type = type;
orientation.dispatchEvent(new Event("change"));
}
});
}

beforeEach(() => {
renderer = null;
viewport = { width: 390, height: 844, coarse: true };
orientation = Object.assign(new EventTarget(), { type: "portrait-primary" });
queries = [];
vi.stubGlobal("window", {
get screen() {
return { orientation };
},
matchMedia: (media: string) => {
const query = Object.assign(new EventTarget(), { media, matches: evaluate(media) });
queries.push(query);
return query;
},
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(async () => {
await act(() => renderer?.unmount());
vi.unstubAllGlobals();
});

it("matches a portrait touch phone", async () => {
await render();
expect(phone()).toBe(true);
});

it("does not match a narrow window with a mouse", async () => {
viewport.coarse = false;
await render();
expect(phone()).toBe(false);
});

it("turns off for a small phone on its side and back on when it is upright", async () => {
viewport = { width: 360, height: 600, coarse: true };
await render();
await rotate(600, 360, "landscape-primary");
expect(phone()).toBe(false);
await rotate(360, 600, "portrait-primary");
expect(phone()).toBe(true);
});

it("stays on when a resizes-content keyboard makes the viewport wider than tall", async () => {
viewport = { width: 412, height: 915, coarse: true };
await render();
await rotate(412, 380);
expect(phone()).toBe(true);
});

it("falls back to the orientation query without screen.orientation", async () => {
orientation = undefined;
viewport = { width: 360, height: 600, coarse: true };
await render();
expect(phone()).toBe(true);
await rotate(600, 360);
expect(phone()).toBe(false);
});
});
32 changes: 32 additions & 0 deletions apps/web/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,35 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string
export function useIsMobile(): boolean {
return useMediaQuery("max-md");
}

function subscribeToScreenOrientation(onChange: () => void): () => void {
const orientation = window.screen?.orientation;
if (orientation) {
orientation.addEventListener("change", onChange);
return () => orientation.removeEventListener("change", onChange);
}
const mql = window.matchMedia("(orientation: portrait)");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}

/** The device's orientation, not the viewport's: under interactive-widget=resizes-content an
Android keyboard shrinks the viewport until it is wider than tall, which an
`(orientation: portrait)` query would read as landscape mid-typing. Safari before 16.4 has no
screen.orientation, and iOS never resizes the layout viewport, so the query is safe there. */
function readScreenIsPortrait(): boolean {
const type = window.screen?.orientation?.type;
if (type) return type.startsWith("portrait");
return window.matchMedia("(orientation: portrait)").matches;
}

/** A portrait touch phone, as opposed to a narrow desktop window or a small phone on its side. */
export function useIsPhoneViewport(): boolean {
const isPhoneSized = useMediaQuery("(max-width: 639px) and (pointer: coarse)");
const isPortrait = useSyncExternalStore(
subscribeToScreenOrientation,
readScreenIsPortrait,
getServerSnapshot,
);
return isPhoneSized && isPortrait;
}
171 changes: 171 additions & 0 deletions apps/web/src/hooks/useVisualViewportSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { act, createElement } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { measureVisualViewport, useVisualViewportSync } from "./useVisualViewportSync";

describe("measureVisualViewport", () => {
it("reports no keyboard when the visible area fills the layout viewport", () => {
expect(measureVisualViewport(844, { height: 844, offsetTop: 0, scale: 1 })).toEqual({
height: 844,
offsetTop: 0,
keyboardInset: 0,
keyboardOpen: false,
});
});

it("measures an iOS keyboard that overlays the page without panning", () => {
expect(measureVisualViewport(844, { height: 508, offsetTop: 0, scale: 1 })).toEqual({
height: 508,
offsetTop: 0,
keyboardInset: 336,
keyboardOpen: true,
});
});

it("keeps the shell on the visible area after iOS pans to reveal the field", () => {
expect(measureVisualViewport(844, { height: 508, offsetTop: 336, scale: 1 })).toEqual({
height: 508,
offsetTop: 336,
keyboardInset: 0,
keyboardOpen: true,
});
});

it("treats a Chromium resizes-content keyboard as a plain smaller viewport", () => {
expect(measureVisualViewport(444, { height: 444, offsetTop: 0, scale: 1 })).toMatchObject({
height: 444,
keyboardInset: 0,
});
});

it("ignores toolbar collapse that is too small to be a keyboard", () => {
expect(measureVisualViewport(844, { height: 770, offsetTop: 0, scale: 1 })?.keyboardOpen).toBe(
false,
);
});

it("stands down while the user is pinch-zoomed", () => {
expect(measureVisualViewport(844, { height: 300, offsetTop: 120, scale: 2 })).toBeNull();
});
});

describe("useVisualViewportSync lifecycle", () => {
let renderer: ReactTestRenderer | null;
let viewport: EventTarget & { height: number; offsetTop: number; scale: number };
let browser: EventTarget & {
innerHeight: number;
innerWidth: number;
visualViewport: typeof viewport;
};
let root: { style: CSSStyleDeclaration; dataset: DOMStringMap };

function Probe({ enabled }: { enabled: boolean }) {
useVisualViewportSync(enabled);
return null;
}

async function render(enabled = true) {
await act(() => {
if (renderer) renderer.update(createElement(Probe, { enabled }));
else renderer = create(createElement(Probe, { enabled }));
});
}

function resize(height: number, offsetTop = 0, layoutHeight = 844) {
Object.assign(viewport, { height, offsetTop });
browser.innerHeight = layoutHeight;
viewport.dispatchEvent(new Event("resize"));
browser.dispatchEvent(new Event("resize"));
}

beforeEach(() => {
renderer = null;
viewport = Object.assign(new EventTarget(), { height: 844, offsetTop: 0, scale: 1 });
browser = Object.assign(new EventTarget(), {
innerHeight: 844,
innerWidth: 390,
visualViewport: viewport,
});
const properties = new Map<string, string>();
root = {
style: {
setProperty: (key: string, value: string) => properties.set(key, value),
removeProperty: (key: string) => properties.delete(key),
getPropertyValue: (key: string) => properties.get(key) ?? "",
} as unknown as CSSStyleDeclaration,
dataset: {},
};
vi.stubGlobal("window", browser);
vi.stubGlobal("document", { documentElement: root });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(async () => {
await act(() => renderer?.unmount());
vi.unstubAllGlobals();
});

it.each(["overlay", "resize"])("survives repeated %s keyboard cycles", async (mode) => {
await render();
for (let cycle = 0; cycle < 5; cycle++) {
resize(444, mode === "overlay" ? 200 : 0, mode === "resize" ? 444 : 844);
expect(root.dataset.keyboardOpen).toBe(mode === "overlay" ? "true" : undefined);
expect(root.style.getPropertyValue("--app-viewport-height")).toBe("444px");
resize(844);
expect(root.dataset.keyboardOpen).toBeUndefined();
expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px");
}
});

it("follows rotation with a Chromium keyboard already open", async () => {
await render();
resize(444, 0, 444);
browser.innerWidth = 844;
resize(190, 0, 190);
expect(root.style.getPropertyValue("--app-viewport-height")).toBe("190px");
expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("0px");
resize(390, 0, 390);
expect(root.style.getPropertyValue("--app-viewport-height")).toBe("390px");
expect(root.dataset.keyboardOpen).toBeUndefined();
});

it("follows a Safari pan that only fires a visual viewport scroll", async () => {
await render();
resize(508);
viewport.offsetTop = 336;
viewport.dispatchEvent(new Event("scroll"));
expect(root.style.getPropertyValue("--app-viewport-offset-top")).toBe("336px");
expect(root.style.getPropertyValue("--keyboard-inset")).toBe("0px");
});

it("updates keyboard state even when the published geometry does not change", async () => {
browser.innerHeight = 600;
Object.assign(viewport, { height: 500, offsetTop: 200 });
await render();
expect(root.dataset.keyboardOpen).toBeUndefined();
resize(500, 200, 650);
expect(root.dataset.keyboardOpen).toBe("true");
});

it("releases the document lock for zoom, route unmount, and desktop", async () => {
await render();
viewport.scale = 2;
resize(300, 120);
expect(root.dataset.viewportSync).toBeUndefined();
expect(root.style.getPropertyValue("--app-viewport-height")).toBe("");
viewport.scale = 1;
resize(508, 336);
expect(root.dataset.viewportSync).toBe("true");
await render(false);
expect(root.dataset).toEqual({});
resize(444);
expect(root.dataset).toEqual({});
await render();
await act(() => renderer?.unmount());
renderer = null;
resize(844);
expect(root.dataset).toEqual({});
expect(root.style.getPropertyValue("--app-viewport-height")).toBe("");
});
});
Loading
Loading