mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## 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
315 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|