From 66867328476bf1cb835a6325f62c849d305a8973 Mon Sep 17 00:00:00 2001
From: Antisophy <293439221+Antisophy@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:05:07 -0700
Subject: [PATCH] feat(web): hide the composer while reading history on narrow
screens
iOS pins the composer above the on-screen keyboard, then lets the two drift
apart as soon as the page scrolls: the box ends up floating over a gap, and
only a drag that starts inside it puts them back together. The page cannot
see that drift, so there is nothing to correct against.
Scrolling up to read is also the moment neither the composer nor the keyboard
is wanted, so leaving the bottom of the message list now hides the composer
and blurs the field, which is what actually dismisses the keyboard. Returning
to the bottom brings the box back; iOS will not raise the keyboard again
without a tap, so that stays manual. Hiding is display:none on a component
that stays mounted, so a draft in progress survives.
Scoped to the same 768px breakpoint the rest of the mobile layout uses:
wider screens have room for the composer and no keyboard to dismiss.
---
web/src/components/InputBox.tsx | 7 ++++++-
web/src/components/MessageList.tsx | 15 +++++++++++++++
web/src/components/SessionView.tsx | 19 +++++++++++++++++++
web/src/styles.css | 7 +++++++
4 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/web/src/components/InputBox.tsx b/web/src/components/InputBox.tsx
index 636f49e9..515c3c40 100644
--- a/web/src/components/InputBox.tsx
+++ b/web/src/components/InputBox.tsx
@@ -15,6 +15,8 @@ const supportsFieldSizing = CSS.supports("field-sizing", "content");
interface CommonProps {
onInterrupt: () => void;
+ /** narrow screens hide the composer while the reader is scrolled up */
+ hidden?: boolean;
isProcessing: boolean;
stdinClosed?: boolean;
disabled: boolean;
@@ -361,6 +363,7 @@ export function InputBox(props: InputBoxProps) {
function OrdinaryInputBox({
onSend,
onInterrupt,
+ hidden = false,
isProcessing,
stdinClosed,
disabled,
@@ -527,7 +530,9 @@ function OrdinaryInputBox({
return (
void;
spawnedTidsByItemId?: Map>;
getTaskHref?: (id: string) => string;
+ /** fires when the list settles at, or leaves, the bottom */
+ onAtBottomChange?: (atBottom: boolean) => void;
}
function ResultMessageView({ message }: { message: DisplayMessage }) {
@@ -851,8 +853,12 @@ export function MessageList({
onViewFile,
spawnedTidsByItemId,
getTaskHref,
+ onAtBottomChange,
}: Props) {
const containerRef = useRef(null);
+ // through a ref so the scroll listener never has to be torn down and rebound
+ const onAtBottomChangeRef = useRef(onAtBottomChange);
+ onAtBottomChangeRef.current = onAtBottomChange;
// Subscribe to outbox so the component re-renders when entries are added/removed.
const [outboxTick, setOutboxTick] = useState(0);
@@ -931,8 +937,17 @@ export function MessageList({
useEffect(() => {
const el = containerRef.current;
if (!el) return;
+ // a little tolerance: momentum scrolling lands a pixel or two off zero
+ const atBottom = () => el.scrollTop >= -24;
+ let wasAtBottom = atBottom();
+ onAtBottomChangeRef.current?.(wasAtBottom);
const onScroll = () => {
el.style.overflowAnchor = el.scrollTop >= -1 ? "none" : "auto";
+ const now = atBottom();
+ if (now !== wasAtBottom) {
+ wasAtBottom = now;
+ onAtBottomChangeRef.current?.(now);
+ }
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
diff --git a/web/src/components/SessionView.tsx b/web/src/components/SessionView.tsx
index 31bc4cd5..396775ac 100644
--- a/web/src/components/SessionView.tsx
+++ b/web/src/components/SessionView.tsx
@@ -96,6 +96,23 @@ function SessionViewInner({
agentUsage,
}: Props) {
const inputRef = useRef(null);
+ // a phone pins the composer above the keyboard, and iOS lets the two drift
+ // apart once the page scrolls, leaving the box floating over a gap that only
+ // a drag starting inside it puts right. Scrolling up to read is also the
+ // moment neither is wanted, so leaving the bottom dismisses both and
+ // returning restores the composer.
+ const [composerHidden, setComposerHidden] = useState(false);
+ const handleAtBottomChange = useCallback((atBottom: boolean) => {
+ const narrow =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(max-width: 768px)").matches;
+ if (!narrow) return;
+ setComposerHidden(!atBottom);
+ // blurring is what dismisses the keyboard; iOS will not raise it again
+ // without a tap, so returning to the bottom restores only the box
+ if (!atBottom) inputRef.current?.blur();
+ }, []);
const insertTextRef = useRef<((text: string) => void) | null>(null);
const pasteTextRef = useRef<((text: string) => void) | null>(null);
const resumeRef = useRef(null);
@@ -415,6 +432,7 @@ function SessionViewInner({
) : (