From df5a78cf91b181ec34f5af234312abf420af600e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment?= <2178406+clempat@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:44:39 +0100 Subject: [PATCH] fix(ui): keep iPhone task chat stable while typing and streaming (#17232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- DESIGN.md | 8 +- ui/src/components/Layout.production.tsx | 13 +- ui/src/components/Layout.tsx | 12 +- ui/src/components/TaskChatThread.test.tsx | 22 +- ui/src/components/TaskChatThread.tsx | 19 +- .../task-chat/TaskChatComposer.test.tsx | 2 +- .../components/task-chat/TaskChatComposer.tsx | 2 +- .../task-chat/TaskChatComposerDock.tsx | 11 +- .../task-chat/TaskChatThreadView.tsx | 2 +- .../task-chat/TaskMessageScroller.test.tsx | 66 +++++ .../task-chat/TaskMessageScroller.tsx | 21 +- .../task-chat/scroll-navigation.tsx | 9 +- .../task-chat/useWindowAutoFollow.test.tsx | 263 ------------------ .../task-chat/useWindowAutoFollow.ts | 136 --------- ui/src/hooks/useMobileVisualViewport.test.tsx | 116 ++++++++ ui/src/hooks/useMobileVisualViewport.ts | 47 ++++ ui/src/index.css | 62 ++++- ui/src/pages/IssueDetail.tsx | 14 +- .../tasks/task-chat-mobile-follow.stories.tsx | 24 +- 19 files changed, 367 insertions(+), 482 deletions(-) delete mode 100644 ui/src/components/task-chat/useWindowAutoFollow.test.tsx delete mode 100644 ui/src/components/task-chat/useWindowAutoFollow.ts create mode 100644 ui/src/hooks/useMobileVisualViewport.test.tsx create mode 100644 ui/src/hooks/useMobileVisualViewport.ts diff --git a/DESIGN.md b/DESIGN.md index a4804cb0e27..bda54f4da2b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -60,8 +60,12 @@ title sizes remain larger. The bottom navigation responds to accumulated scrolling, ignoring small reversals and Safari's edge bounce. It glides out and eases back in with shared -motion tokens; the task composer follows the same motion. Keep page padding -stable while the navigation moves, and honor reduced-motion preferences. +motion tokens. Keep page padding stable while the navigation moves, and honor +reduced-motion preferences. + +Mobile task conversations size their shell to the visible viewport, with one +message scroller and a normal flex footer for the composer. Navigation stays at +the layout viewport bottom, behind the keyboard. Cold task opens reveal the instruction and recent conversation independently of plans, related metadata, and historical run activity. Show the latest human diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 56afb99fc06..cdb984d85d7 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -45,6 +45,7 @@ import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility"; +import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport"; import { healthApi } from "../api/health"; import { resolveArchivedCompanyBounce, @@ -178,6 +179,7 @@ export function Layout() { const scrollMemory = useRef(new NavigationScrollMemory()); const activeScrollKey = useRef(location.key); const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname); + const mobileViewportRef = useMobileVisualViewport(isMobile); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -588,8 +590,10 @@ export function Layout() { return (
@@ -663,12 +667,13 @@ export function Layout() {
) : null}
-
+
(location.key); const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname); + const mobileViewportRef = useMobileVisualViewport(isMobile); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -574,8 +576,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { return (
-
+
{isMobile && sidebarOpen && (