`.
+ *
+ * ShellFrame's horizontal-nav layout stacks the 56px `TopNav` above `
`
+ * in a `100dvh` flex column, so `` is only `100dvh - 56px` tall. Screens
+ * that hardcoded `height: 100vh` were therefore taller than their container,
+ * which overflowed `` and made the whole page scroll — dragging chat
+ * headers/inputs out of view instead of scrolling just the body (issue #331).
+ *
+ * Filling the parent with `height: 100%` pins the screen to `` exactly,
+ * so inner regions scroll internally. `` has a definite height in both
+ * the sidebar and top-nav layouts, so the percentage resolves correctly in
+ * each — keeping the sidebar layout unaffected.
+ *
+ * Use this instead of `height: 100vh` for any full-height shell screen.
+ */
+export function FullHeightScreen({
+ children,
+ direction = "column",
+ className,
+ style,
+}: {
+ children: React.ReactNode;
+ /** Flex direction of the root. Defaults to "column". */
+ direction?: "row" | "column";
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ShellFrame.tsx b/frontend/src/components/ShellFrame.tsx
index 1f089754..fa9807a4 100644
--- a/frontend/src/components/ShellFrame.tsx
+++ b/frontend/src/components/ShellFrame.tsx
@@ -11,6 +11,16 @@ import { AchievementUnlockWatcher } from "./AchievementUnlockWatcher";
import { useLayoutPref } from "@/lib/useLayoutPref";
import { useIsMobile } from "@/lib/useIsMobile";
+/**
+ * `100dvh`, not `100vh`: on iOS Safari `100vh` is the *large* viewport (the
+ * height with the toolbar collapsed), so with the toolbar expanded a `100vh`
+ * shell runs past the visual viewport and hides its own bottom edge — the
+ * `/learn` composer, most visibly. Screens now fill `` exactly (#331),
+ * so there is no leftover scroll slack to drag that edge back into view.
+ * `100dvh` tracks the visual viewport instead, and since the shell itself
+ * never scrolls (`overflow: hidden`, inner panes scroll), the toolbar never
+ * collapses and the value stays put — no resize thrash.
+ */
export function ShellFrame({ children }: { children: React.ReactNode }) {
const [pref] = useLayoutPref();
const isMobile = useIsMobile();
@@ -22,7 +32,7 @@ export function ShellFrame({ children }: { children: React.ReactNode }) {
style={{
display: "flex",
flexDirection: "row",
- height: "100vh",
+ height: "100dvh",
overflow: "hidden",
position: "relative",
}}
@@ -58,7 +68,7 @@ export function ShellFrame({ children }: { children: React.ReactNode }) {
style={{
display: "flex",
flexDirection: "column",
- height: "100vh",
+ height: "100dvh",
overflow: "hidden",
position: "relative",
}}
diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx
index 46ca8b96..954a8d64 100644
--- a/frontend/src/components/screens/Learn.tsx
+++ b/frontend/src/components/screens/Learn.tsx
@@ -7,6 +7,7 @@ import { TopBar } from "../TopBar";
import { Icon } from "../Icon";
import { CustomSelect } from "../CustomSelect";
import { ChatPanel, type ChatMsg } from "../ChatPanel";
+import { FullHeightScreen } from "../FullHeightScreen";
import { SessionSummary } from "../SessionSummary";
import { SharedContextToggle, useSharedContext } from "../SharedContextToggle";
import { ModelToggle, useModelPref } from "../ModelToggle";
@@ -430,7 +431,7 @@ function LearnInner() {
// ────────── Entry screen (no active session) ──────────
if (!sessionId && !starting) {
return (
-
+
-
+
);
}
// ────────── Active session ──────────
return (
-
+
{isMobile && (
@@ -650,7 +651,7 @@ function LearnInner() {
onStartNext={startNextFromSummary}
/>
)}
-
+
);
}
diff --git a/frontend/src/components/screens/Library.tsx b/frontend/src/components/screens/Library.tsx
index f5b96d7f..e00359f9 100644
--- a/frontend/src/components/screens/Library.tsx
+++ b/frontend/src/components/screens/Library.tsx
@@ -3,6 +3,7 @@ import React from "react";
import dynamic from "next/dynamic";
import { createPortal } from "react-dom";
import { TopBar } from "../TopBar";
+import { FullHeightScreen } from "../FullHeightScreen";
import { Icon } from "../Icon";
import { Pill } from "../Pill";
import { DocumentUploadModal } from "../DocumentUploadModal";
@@ -137,7 +138,7 @@ export function Library() {
}, [documents]);
return (
-
+
-
+
);
}
diff --git a/frontend/src/components/screens/Quiz.tsx b/frontend/src/components/screens/Quiz.tsx
index 81a52772..178bc3c9 100644
--- a/frontend/src/components/screens/Quiz.tsx
+++ b/frontend/src/components/screens/Quiz.tsx
@@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { TopBar } from "../TopBar";
+import { FullHeightScreen } from "../FullHeightScreen";
import { AIDisclaimerChip } from "../AIDisclaimerChip";
import { DisclaimerModal } from "../DisclaimerModal";
import { QuizPanel } from "../QuizPanel";
@@ -72,7 +73,7 @@ function QuizInner() {
}, [conceptParam, topicParam, concepts]);
return (
-
+
) : null}
-
+
);
}
diff --git a/frontend/src/components/screens/Settings.tsx b/frontend/src/components/screens/Settings.tsx
index 2254ce43..96211740 100644
--- a/frontend/src/components/screens/Settings.tsx
+++ b/frontend/src/components/screens/Settings.tsx
@@ -2,6 +2,7 @@
import React from "react";
import Link from "next/link";
import { TopBar } from "../TopBar";
+import { FullHeightScreen } from "../FullHeightScreen";
import { Icon } from "../Icon";
import { Avatar } from "../Avatar";
import { CustomSelect } from "../CustomSelect";
@@ -216,7 +217,7 @@ export function Settings() {
const tabs: Tab[] = ["profile", "preferences", "notifications", "data"];
return (
-
+
}
/>
-
+ {/* Fills whatever `
` leaves below TopBar. `minHeight: 0` lets this
+ shrink past its content so the panes below scroll internally rather
+ than pushing the screen taller than ``. */}
+
{!settings && tab !== "cosmetics" &&
}
{tab === "profile" && settings && (
@@ -635,7 +639,7 @@ export function Settings() {
{previewOpen && preview && (
setPreviewOpen(false)} />
)}
-
+
);
}
diff --git a/frontend/src/components/screens/Social.tsx b/frontend/src/components/screens/Social.tsx
index d5cd9404..45753b65 100644
--- a/frontend/src/components/screens/Social.tsx
+++ b/frontend/src/components/screens/Social.tsx
@@ -3,6 +3,7 @@ import React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Icon } from "../Icon";
+import { FullHeightScreen } from "../FullHeightScreen";
import { Avatar } from "../Avatar";
import { CustomSelect } from "../CustomSelect";
import { SocialRoomsSkeleton } from "../Skeleton";
@@ -1043,7 +1044,7 @@ export function Social() {
const members = (overview?.members || []).map(m => ({ user_id: m.user_id, name: m.name }));
return (
-
+
{tab === "directory" ? : active ? (
<>
@@ -1124,7 +1125,7 @@ export function Social() {
))}
-
+
);
}
diff --git a/frontend/src/components/screens/Study.tsx b/frontend/src/components/screens/Study.tsx
index d0aa6f41..b94c83b3 100644
--- a/frontend/src/components/screens/Study.tsx
+++ b/frontend/src/components/screens/Study.tsx
@@ -116,7 +116,7 @@ export function Study() {
);
return (
-