Files
PaperClipAI/ui/src/components/task-chat/TaskMessageScroller.tsx
T
Clément df5a78cf91 fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
## Thinking Path

> - Paperclip keeps human-agent conversations attached to tasks.
> - People need to read and reply comfortably from a phone.
> - The mobile task page scrolled the document and positioned its
composer with a sticky navigation offset.
> - Safari's keyboard resize and later focus pan could move that
document again, causing overlap, gaps, and jumps while editing.
> - This change gives mobile and desktop the same message scroller and a
normal flex footer.
> - CSS owns the layout. A small VisualViewport adapter handles the
visible height and Safari's stale document focus scroll.
> - Streaming no longer measures every historical row while following
the bottom.

## Linked Issues or Issue Description

Fixes https://github.com/paperclipai/paperclip/issues/16497

Related: https://github.com/paperclipai/paperclip/pull/15245 also
addresses keyboard positioning. This change additionally removes mobile
document auto-follow and reduces historical-row measurement work.

## What Changed

### One scroll owner and a natural-flow composer

Before:

```text
Document scroll + mobile auto-follow
  └─ Conversation + sticky composer + animated navigation offset
```

After:

```text
Height-constrained conversation shell
  ├─ Header
  ├─ Message scroller — owns follow/reading position
  └─ Composer — normal flex footer
Navigation stays at the layout viewport bottom, behind the keyboard
```

- Reuse TaskMessageScroller on mobile. Delete the separate 136-line
useWindowAutoFollow implementation and its superseded tests.
- Keep the composer in normal flex flow. Remove its sticky bottom
positioning and navigation-offset animation.
- Constrain the mobile conversation with CSS. Classic tasks and other
pages retain their existing layout.
- Add a 47-line useMobileVisualViewport hook. It changes shell height on
resize, not on typing or incoming messages. It never translates the
shell or hides navigation.
- Reject Safari's stale document focus pan only for an unzoomed,
keyboard-open conversation. Inner message and editor scrolling stay
native.
- Keep a saved reading anchor when reading older messages. Skip
historical-row measurements while following the bottom.
- Update tests and the existing mobile streaming fixture. Keep only two
layout-contract sentences in DESIGN.md.

**Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall.
Production UI/CSS alone is +158 / -190 lines. No API, database, or
dependency changes.

## Verification

Bottom-follow is also saved explicitly for Back navigation. A new
regression covers returning after rotation changes message wrapping,
without restoring historical-row scans.

### Physical iPhone acceptance

Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed:

- Opening the keyboard positions the composer correctly immediately,
before any typing.
- The installed web app works.
- Long drafts, insertion, deletion, and sending are acceptable. The
reporter described long drafts as not perfect but good enough.
- Reading older messages during incoming output preserves position;
returning to the bottom resumes following.
- Rotation, keyboard close/reopen, and attachment/picker transitions
work.

Remote inspection corroborated the corrected open-keyboard state:
visible height 358px, composer bottom 358px, document scrollY 0.
Navigation remained display:block at y=631px, behind the keyboard.

Earlier candidates failed device testing. The decisive trace showed a
correct viewport resize followed about one frame later by a stale 337px
native document focus pan. Typing caused Safari to correct it. Tests now
cover that event ordering, the reverse ordering, and zoom panning.

### Performance evidence

The existing TaskChatPerfHarness supplied 200 historical responses,
4,000 collapsed tools, and 30 updates at 100ms intervals.

| Measurement | Before | After |
| --- | ---: | ---: |
| Historical row geometry reads | 6,000 | 0 |
| Renderer main-thread work | 331.2ms | 273.5ms |
| Largest task | 27.1ms | 12.3ms |

This is one instrumented Chromium development run on the Mac, not a
phone benchmark or a statistical speedup claim. The zero-row-measurement
result also has a regression test that failed with 6,000 reads before
the change.

To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite
server. Record 30 streaming updates at the bottom. Inspect
TaskMessageScroller and row getBoundingClientRect calls. Existing
historical-render tests also confirm that unchanged Markdown does not
reparse on live-tail updates.

### Automated and full-app checks

- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031)
is green: 54 successful checks and 2 skipped checks. Greptile rates the
current head 5/5, confirms the Back-navigation fix, and has no
unresolved findings.

- **601 focused tests pass** across the task page, layout, composer,
scroller, viewport adapter, and historical-render coverage.
- UI typecheck and production build pass.
- Token gates, Storybook catalog checks, and catalog/map structural
tests pass.
- Independent review of the final source tree passed with no findings.
- The full source app ran on macOS with an isolated database and saved
conversation history. A reply sent through the real UI persisted after
reload. Provider execution was not part of this layout test.
- **Full local repository checks did not pass.** Build/typecheck stop at
missing Rust cargo. The full test run reported 142 failed server tests
and 3 failed suites; missing cargo is confirmed in those three suites.
The other server failures were not diagnosed in this UI task.
Current-head CI now supplies the repository-wide evidence; these local
failures remain disclosed.

## Risks

- Mobile conversations now share the desktop scroll owner. Navigation,
loading, history restoration, and reading-position tests cover that
change.
- The keyboard-state threshold is a 120px viewport reduction.
Accessory-only keyboards retain the normal navigation spacing.
- The document-scroll fallback is scoped to rendered conversations with
an open keyboard at scale 1. It does not handle inner scrolling or
prevent zoom panning.
- Long-draft behavior is accepted, with minor polish still possible.
- The UI change is reversible and requires no data migration.

## Model Used

OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode.
Capabilities used: reasoning, code editing, tests, Chromium performance
tracing, and Safari remote inspection. The harness does not expose the
context-window size. An independent general-purpose subagent reviewed
the final tree; its serving model ID was not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with Fixes / Closes /
Refs OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; repository-wide
failures are disclosed above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-10-26 08:44:39 -07:00

315 lines
12 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { ArrowDown } from "lucide-react";
import { parseCssTimeMs } from "./motion-tokens";
import { useTaskChatScrollNavigation } from "./scroll-navigation";
const PIN_THRESHOLD_PX = 48;
/** Visibility lifecycle of the scroll-to-latest pill. */
type PillPhase = "hidden" | "in" | "out";
/**
* True when animations are disabled (prefers-reduced-motion, or environments
* without matchMedia such as jsdom). In that case the pill's exit animation
* never fires animationend, so we must unmount immediately.
*/
function motionDisabled(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
interface TaskMessageScrollerProps {
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
contentKey: unknown;
className?: string;
}
/**
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
* threshold) new content auto-follows with INSTANT scroll; the moment the user
* scrolls up we hold their position and surface a scroll-to-latest pill
* instead of yanking them down.
*
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
* the smooth scroll fires intermediate scroll events that would otherwise
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
* within the pin threshold re-pins and hides the pill; a wheel/touch during
* the glide cancels it and treats the user as unpinned. Content-driven follow
* while pinned stays instant, so no reflow/jump happens during streaming.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const navigation = useTaskChatScrollNavigation();
const initialPositionApplied = useRef(false);
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
const ref = useRef<HTMLDivElement>(null);
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
const pinnedRef = useRef(true);
const easingRef = useRef(false);
const clientHeightRef = useRef<number | null>(null);
const scrollbarIdleTimerRef = useRef<number | null>(null);
const scrollbarIdleDelayRef = useRef<number | null>(null);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const showScrollbarWhileScrolling = useCallback(() => {
const el = ref.current;
if (!el) return;
el.dataset.scrollActive = "true";
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
const idleDelay = scrollbarIdleDelayRef.current ?? parseCssTimeMs(
getComputedStyle(document.documentElement).getPropertyValue("--motion-scrollbar-idle-delay"),
);
scrollbarIdleDelayRef.current = idleDelay;
scrollbarIdleTimerRef.current = window.setTimeout(() => {
delete el.dataset.scrollActive;
scrollbarIdleTimerRef.current = null;
scrollbarIdleDelayRef.current = null;
}, idleDelay);
}, []);
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
const hidePill = useCallback(() => {
// Keep rendering through the exit animation; unmount on animationend.
// Without animations (reduced motion / no matchMedia) unmount immediately.
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
}, []);
const isPinned = useCallback(() => {
const el = ref.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD_PX;
}, []);
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
el.scrollTop = el.scrollHeight; // instant, never smooth
}, []);
const followViewportResize = useCallback(() => {
const el = ref.current;
if (!el) return false;
const previousClientHeight = clientHeightRef.current;
const nextClientHeight = el.clientHeight;
clientHeightRef.current = nextClientHeight;
if (
previousClientHeight == null ||
previousClientHeight <= 0 ||
previousClientHeight === nextClientHeight ||
!pinnedRef.current
) {
return false;
}
scrollToBottom();
return true;
}, [scrollToBottom]);
const rememberAnchor = useCallback((capture = true) => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
if (pinnedRef.current) {
// Following the bottom needs only an offset, not a scan of every old row.
anchorRef.current = null;
} else if (capture) {
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
}
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current);
}, [navigation.key, navigation.hash, navigation.ready]);
const reconcileContent = useCallback(() => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
// Clicking latest is an explicit follow intent. If content or the composer
// changes during its glide, finish at the new bottom instead of restoring
// the old reading anchor and cancelling the browser's smooth scroll.
if (easingRef.current) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
if (pinnedRef.current) scrollToBottom();
else {
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
if (delta) el.scrollTop += delta;
}
// Compensation preserves the existing reading anchor. Recapture only when
// the reader scrolls or navigation selects a different position.
rememberAnchor(false);
}, [rememberAnchor, scrollToBottom, hidePill]);
const handleScroll = useCallback(() => {
if (!ref.current?.clientHeight) return;
showScrollbarWhileScrolling();
// A growing composer shrinks this viewport. Some browsers dispatch the
// resulting scroll event before ResizeObserver, so preserve the previous
// pinned state here instead of mistaking the layout change for a user
// scroll away from the bottom.
if (followViewportResize()) {
rememberAnchor(false);
hidePill();
return;
}
const pinned = isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
// unpin/re-show the pill. Only act once we arrive within the threshold.
if (pinned) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
rememberAnchor();
return;
}
pinnedRef.current = pinned;
rememberAnchor();
if (pinned) hidePill();
else showPill();
}, [
rememberAnchor,
followViewportResize,
isPinned,
hidePill,
showPill,
showScrollbarWhileScrolling,
]);
const handleJumpToLatest = useCallback(() => {
const el = ref.current;
if (!el) return;
if (isPinned()) {
// Already at (or within threshold of) the bottom: no glide needed.
pinnedRef.current = true;
hidePill();
return;
}
easingRef.current = true;
if (typeof el.scrollTo === "function" && !motionDisabled()) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} else {
// Environments without scrollTo (older jsdom): fall back to instant.
el.scrollTop = el.scrollHeight;
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
}, [isPinned, hidePill]);
// A user gesture during the smooth glide cancels the re-follow: stop
// treating scroll events as easing and consider the user unpinned.
useEffect(() => {
const el = ref.current;
if (!el) return;
const cancelEasing = () => {
if (!easingRef.current) return;
easingRef.current = false;
pinnedRef.current = false;
showPill();
};
el.addEventListener("wheel", cancelEasing, { passive: true });
el.addEventListener("touchstart", cancelEasing, { passive: true });
return () => {
el.removeEventListener("wheel", cancelEasing);
el.removeEventListener("touchstart", cancelEasing);
};
}, [showPill]);
useEffect(() => () => {
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
}, []);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
clientHeightRef.current = el.clientHeight;
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (followViewportResize()) hidePill();
reconcileContent();
});
observer.observe(el);
// The viewport itself does not resize when an image or historical row
// grows. Observe the content box too, before the browser paints it.
if (el.firstElementChild) observer.observe(el.firstElementChild);
return () => observer.disconnect();
}, [followViewportResize, hidePill, reconcileContent]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
const el = ref.current;
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
initialPositionApplied.current = false;
}
if (el && el.clientHeight > 0 && navigation.ready && !initialPositionApplied.current) {
const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop);
if (top !== null) {
el.scrollTop = top;
pinnedRef.current = isPinned();
rememberAnchor();
}
initialPositionApplied.current = true;
}
reconcileContent();
}, [contentKey, reconcileContent, navigation.key, navigation.hash, navigation.ready]);
return (
<div className="relative min-h-0 flex-1">
<div
ref={ref}
onScroll={handleScroll}
// Keep the viewport tied to the flex-sized wrapper vertically —
// percentage heights don't reliably resolve against flex-determined
// block heights, which let the thread overflow the page. In the
// streamlined shell, extend only the scroll box through the page's
// right gutter; matching padding preserves the message column while
// placing the scrollbar against the properties-panel boundary.
className={cn(
"task-chat-scroll-viewport scrollbar-while-scrolling absolute inset-y-0 left-0 overflow-y-auto",
streamlined
? "-right-4 overflow-x-hidden pr-4 md:-right-6 md:pr-6"
: "right-0",
className,
)}
data-testid="task-chat-scroller"
>
{children}
</div>
{pillPhase !== "hidden" ? (
<button
type="button"
aria-label="Scroll to latest"
onClick={handleJumpToLatest}
onAnimationEnd={() => {
if (pillPhase === "out") setPillPhase("hidden");
}}
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
className={cn(
"absolute left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
streamlined ? "bottom-7" : "bottom-3",
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
)}
>
<ArrowDown className="h-4 w-4" />
</button>
) : null}
</div>
);
}