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({
) : (