mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
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
This commit is contained in:
1 parent
5823e9a664
commit
df5a78cf91
19 files changed
+367
-482
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string>(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 (
|
||||
<ChatSetupSidebarProvider>
|
||||
<div
|
||||
ref={mobileViewportRef}
|
||||
data-mobile={isMobile ? "true" : undefined}
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-(--sz-safe-top)",
|
||||
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
||||
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
||||
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
||||
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
||||
@@ -609,7 +613,7 @@ export function Layout() {
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
"app-main-row min-h-0 flex-1",
|
||||
isMobile ? "w-full" : "flex overflow-clip",
|
||||
)}
|
||||
>
|
||||
@@ -663,12 +667,13 @@ export function Layout() {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col",
|
||||
"app-content-column flex min-w-0 flex-col",
|
||||
isMobile ? "w-full" : "h-full flex-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"app-breadcrumb",
|
||||
isMobile &&
|
||||
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
@@ -681,7 +686,7 @@ export function Layout() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<div className={cn("app-content-row", isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
id="main-content"
|
||||
ref={mainContentRef}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
|
||||
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
|
||||
import { healthApi } from "../api/health";
|
||||
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
@@ -143,6 +144,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
const scrollMemory = useRef(new NavigationScrollMemory());
|
||||
const activeScrollKey = useRef<string>(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 (
|
||||
<ChatSetupSidebarProvider>
|
||||
<div
|
||||
ref={mobileViewportRef}
|
||||
data-mobile={isMobile ? "true" : undefined}
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-(--sz-safe-top)",
|
||||
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
||||
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
||||
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
||||
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
||||
@@ -591,7 +595,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
||||
<div className={cn("app-main-row min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -656,9 +660,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
</SecondarySidebar>
|
||||
) : null}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div className={cn("app-content-column flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"app-breadcrumb",
|
||||
!isMobile && useStreamlinedTaskDetailShell && "hidden",
|
||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
@@ -672,6 +677,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"app-content-row",
|
||||
isMobile ? "block" : "flex flex-1 min-h-0",
|
||||
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
|
||||
)}>
|
||||
|
||||
@@ -3535,7 +3535,7 @@ describe("TaskChatThread composer alignment", () => {
|
||||
|
||||
const dock = container
|
||||
.querySelector('[data-testid="mock-editor"]')
|
||||
?.closest("div.sticky") as HTMLElement | null;
|
||||
?.closest('[data-testid="task-chat-composer-dock"]') as HTMLElement | null;
|
||||
|
||||
expect(dock?.className).toContain("w-full");
|
||||
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
|
||||
@@ -4319,26 +4319,22 @@ describe("TaskChatThread Paperclip Runner queue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
|
||||
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
|
||||
describe("TaskChatThread mobile composer dock", () => {
|
||||
it("scrolls messages independently of the composer on mobile", () => {
|
||||
sidebarState.isMobile = true;
|
||||
|
||||
renderThread({
|
||||
comments: [],
|
||||
comments: createLongThreadComments(),
|
||||
onAdd: async () => {},
|
||||
draftKey: "task-chat-draft:issue-mobile",
|
||||
});
|
||||
|
||||
const dock = container
|
||||
.querySelector('[data-testid="mock-editor"]')
|
||||
?.closest("div.sticky") as HTMLElement | null;
|
||||
|
||||
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
|
||||
const scroller = container.querySelector('[data-testid="task-chat-scroller"]');
|
||||
expect(dock).not.toBeNull();
|
||||
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
|
||||
// height while the nav is on screen) — NOT the raw safe-area dock, which is
|
||||
// what let the nav occlude the action row before PAP-495.
|
||||
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
|
||||
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
|
||||
expect(scroller).not.toBeNull();
|
||||
expect(scroller?.textContent).toContain("Thread message 1");
|
||||
expect(scroller?.contains(dock)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ import {
|
||||
useRunnerGoalControl,
|
||||
} from "@/components/task-chat/RunnerGoalWidget";
|
||||
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
|
||||
import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
|
||||
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
|
||||
@@ -3040,25 +3039,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
|
||||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||||
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
|
||||
<TaskChatWindowScroll
|
||||
contentKey={isMobile ? autoFollowContentKey : 0}
|
||||
enabled={isMobile && historyRevealed}
|
||||
/>
|
||||
<TaskChatPresentationProvider
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isMobile && !historyRevealed && "task-chat-history-pending",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
|
||||
"relative flex min-h-0 flex-1 flex-col",
|
||||
)}
|
||||
aria-busy={!historyRevealed}
|
||||
>
|
||||
@@ -3093,8 +3086,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<div
|
||||
data-testid="task-chat-history-content"
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
!historyRevealed && "invisible",
|
||||
)}
|
||||
inert={!historyRevealed}
|
||||
@@ -3103,9 +3095,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<>
|
||||
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
|
||||
<div
|
||||
className={
|
||||
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
@@ -3241,7 +3231,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
}
|
||||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -856,7 +856,7 @@ describe("TaskChatComposer", () => {
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
|
||||
|
||||
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
|
||||
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
|
||||
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
|
||||
});
|
||||
|
||||
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
|
||||
|
||||
@@ -1375,7 +1375,7 @@ export function TaskChatComposer({
|
||||
className={cn(disabled && "opacity-60")}
|
||||
contentClassName={
|
||||
mobile
|
||||
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
||||
? "max-h-(--tc-composer-editor-max-height) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
||||
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface TaskChatComposerDockProps {
|
||||
concealed?: boolean;
|
||||
}
|
||||
|
||||
/** The task thread and its Storybook phone previews share the same composer spacing. */
|
||||
/** A normal flex footer beside the message scroller, on desktop and mobile. */
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
|
||||
return <div
|
||||
data-testid="task-chat-composer-dock"
|
||||
@@ -16,14 +16,11 @@ export function TaskChatComposerDock({ children, mobile, streamlined, concealed
|
||||
inert={concealed}
|
||||
aria-hidden={concealed || undefined}
|
||||
className={cn(
|
||||
"sticky flex max-w-(--tc-shell-max-w) flex-col gap-2",
|
||||
"flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
|
||||
concealed && "invisible",
|
||||
mobile
|
||||
? cn(
|
||||
"mobile-nav-composer bottom-(--tc-composer-bottom) z-20 w-auto px-1 pb-1",
|
||||
streamlined ? "-mx-2" : "mx-2",
|
||||
)
|
||||
: "bottom-0 z-10 mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
|
||||
? cn("w-auto px-1 pb-1", streamlined ? "-mx-2" : "mx-2")
|
||||
: "mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
|
||||
streamlined && "md:px-0 md:pb-0",
|
||||
(!streamlined || mobile) &&
|
||||
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
|
||||
|
||||
@@ -435,7 +435,7 @@ export function TaskChatThreadView({
|
||||
|
||||
// Cheap content signature so streaming growth (text lengthening without the
|
||||
// item count changing) still advances the auto-follow key. Shared by the
|
||||
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
|
||||
// shared message scroller on desktop and mobile (TaskChatThread).
|
||||
function signatureOf(it: TaskChatItem): number {
|
||||
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
|
||||
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
|
||||
|
||||
@@ -125,6 +125,36 @@ describe("TaskMessageScroller", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not measure historical rows on each streaming update while following the bottom", () => {
|
||||
let initialized = false;
|
||||
let rowMeasurements = 0;
|
||||
const update = (tick: number) => flushSync(() => root.render(
|
||||
<TaskMessageScroller contentKey={tick}>
|
||||
<div ref={(node) => {
|
||||
if (node && !initialized) {
|
||||
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
|
||||
initialized = true;
|
||||
}
|
||||
}}>
|
||||
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
|
||||
if (node) node.getBoundingClientRect = () => {
|
||||
rowMeasurements++;
|
||||
const top = index * 100 - scroller().scrollTop;
|
||||
return { top, bottom: top + 100, height: 100 } as DOMRect;
|
||||
};
|
||||
}}>History {index}</div>)}
|
||||
<p>Live response {tick}</p>
|
||||
</div>
|
||||
</TaskMessageScroller>,
|
||||
));
|
||||
update(0);
|
||||
rowMeasurements = 0;
|
||||
for (let tick = 1; tick <= 30; tick++) update(tick);
|
||||
expect(container.textContent).toContain("Live response 30");
|
||||
expect(scroller().scrollTop).toBe(20000);
|
||||
expect(rowMeasurements).toBe(0);
|
||||
});
|
||||
|
||||
it("applies same-task hash changes and restores each history entry without remounting", async () => {
|
||||
let initialized = false;
|
||||
function navigate(key: string, hash: string, restore = false) {
|
||||
@@ -178,6 +208,42 @@ describe("TaskMessageScroller", () => {
|
||||
expect(scroller().scrollTop).toBe(150);
|
||||
});
|
||||
|
||||
it("restores bottom-follow on Back after the viewport and message heights change", () => {
|
||||
const configured = new WeakSet<HTMLElement>();
|
||||
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
|
||||
flushSync(() => root.render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
|
||||
<TaskMessageScroller contentKey={scrollHeight}>
|
||||
<div ref={(node) => {
|
||||
const viewport = node?.parentElement;
|
||||
if (viewport && !configured.has(viewport)) {
|
||||
fakeGeometry(viewport, { scrollHeight, clientHeight });
|
||||
let top = 0;
|
||||
Object.defineProperty(viewport, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => top,
|
||||
set: (value: number) => {
|
||||
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
|
||||
},
|
||||
});
|
||||
configured.add(viewport);
|
||||
}
|
||||
}}>Latest response</div>
|
||||
</TaskMessageScroller>
|
||||
</TaskChatScrollNavigation.Provider>,
|
||||
));
|
||||
}
|
||||
mount(false, 1000, 400);
|
||||
flushSync(() => root.render(null));
|
||||
// Rotation changed wrapping and the available message viewport while away.
|
||||
mount(true, 2000, 500);
|
||||
expect(scroller().scrollTop).toBe(1500);
|
||||
expect(pill()).toBeNull();
|
||||
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
|
||||
mount(true, 2400, 500);
|
||||
expect(scroller().scrollTop).toBe(1900);
|
||||
});
|
||||
|
||||
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
|
||||
render();
|
||||
const el = scroller();
|
||||
|
||||
@@ -116,12 +116,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
return true;
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const rememberAnchor = useCallback(() => {
|
||||
const rememberAnchor = useCallback((capture = true) => {
|
||||
const el = ref.current;
|
||||
if (!el || el.clientHeight === 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
|
||||
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current);
|
||||
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(() => {
|
||||
@@ -140,18 +145,20 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
|
||||
if (delta) el.scrollTop += delta;
|
||||
}
|
||||
rememberAnchor();
|
||||
// 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;
|
||||
rememberAnchor();
|
||||
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;
|
||||
}
|
||||
@@ -164,9 +171,11 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
pinnedRef.current = true;
|
||||
hidePill();
|
||||
}
|
||||
rememberAnchor();
|
||||
return;
|
||||
}
|
||||
pinnedRef.current = pinned;
|
||||
rememberAnchor();
|
||||
if (pinned) hidePill();
|
||||
else showPill();
|
||||
}, [
|
||||
|
||||
@@ -16,7 +16,7 @@ export function taskChatScrollEntry(location: { key: string; hash: string; pathn
|
||||
|
||||
// Scoped to browser-history entries, not issues: opening the same task from a
|
||||
// new Inbox click starts at latest, while Back restores the previous reading.
|
||||
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null }>();
|
||||
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null; followBottom: boolean }>();
|
||||
|
||||
export function useTaskChatScrollNavigation() {
|
||||
const navigation = useContext(TaskChatScrollNavigation);
|
||||
@@ -32,6 +32,9 @@ export function useTaskChatScrollNavigation() {
|
||||
if (!navigation) return null;
|
||||
if (navigation.restore && positionKey && positions.has(positionKey)) {
|
||||
const saved = positions.get(positionKey)!;
|
||||
// A saved pixel offset goes stale when rotation changes text wrapping.
|
||||
// Bottom-follow needs no row measurements, even across navigation.
|
||||
if (saved.followBottom) return Math.max(0, root.scrollHeight - root.clientHeight);
|
||||
if (saved.anchor && [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].some((row) => row.dataset.threadAnchor === saved.anchor?.id)) {
|
||||
return scrollTop + threadScrollAnchorDelta(root, saved.anchor, viewportTop);
|
||||
}
|
||||
@@ -45,9 +48,9 @@ export function useTaskChatScrollNavigation() {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
remember(top: number, anchor: ThreadScrollAnchor | null) {
|
||||
remember(top: number, anchor: ThreadScrollAnchor | null, followBottom: boolean) {
|
||||
if (!positionKey || !ready) return;
|
||||
positions.set(positionKey, { top, anchor });
|
||||
positions.set(positionKey, { top, anchor, followBottom });
|
||||
if (positions.size > 100) positions.delete(positions.keys().next().value!);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWindowAutoFollow } from "./useWindowAutoFollow";
|
||||
import { TaskChatScrollNavigation } from "./scroll-navigation";
|
||||
|
||||
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
|
||||
useWindowAutoFollow(contentKey, enabled);
|
||||
return <div data-testid="task-chat-thread">thread</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom has no layout: document scroll geometry is faked on the scrolling
|
||||
* element, window.scrollTo is stubbed to record calls and update scrollY.
|
||||
*/
|
||||
describe("useWindowAutoFollow", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let scrollToCalls: number[];
|
||||
|
||||
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
let currentScrollHeight = scrollHeight;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
get: () => currentScrollHeight,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
|
||||
return {
|
||||
setScrollHeight(value: number) {
|
||||
currentScrollHeight = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setWindowScrollY(y: number) {
|
||||
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
|
||||
}
|
||||
|
||||
function render(contentKey: unknown, enabled = true) {
|
||||
flushSync(() => {
|
||||
root.render(<Host contentKey={contentKey} enabled={enabled} />);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire a window scroll event and flush the continuous-priority update. */
|
||||
function scrollWindowTo(y: number) {
|
||||
setWindowScrollY(y);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
|
||||
function flushRaf() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => setTimeout(resolve));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
scrollToCalls = [];
|
||||
fakeWindowGeometry();
|
||||
setWindowScrollY(0);
|
||||
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
|
||||
const top = typeof options === "number" ? options : (options?.top ?? 0);
|
||||
scrollToCalls.push(top);
|
||||
// Mirror the browser: a follow to the bottom leaves the window pinned.
|
||||
setWindowScrollY(Math.max(0, top - window.innerHeight));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
|
||||
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
|
||||
function MobileThread() {
|
||||
useWindowAutoFollow("unchanged", true);
|
||||
return <div data-testid="task-chat-thread">{[100, 500].map((top, index) => (
|
||||
<div key={index} id={`mobile-comment-${index}`} data-thread-anchor={`mobile-comment-${index}`} ref={(node) => {
|
||||
if (node) node.getBoundingClientRect = () => ({ top: top - window.scrollY, bottom: top + 100 - window.scrollY, height: 100 } as DOMRect);
|
||||
}}>Comment {index}</div>
|
||||
))}</div>;
|
||||
}
|
||||
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}><MobileThread /></TaskChatScrollNavigation.Provider>,
|
||||
));
|
||||
navigate("mobile-entry-one", "#mobile-comment-0");
|
||||
const thread = container.firstElementChild;
|
||||
expect(window.scrollY).toBe(100);
|
||||
await scrollWindowTo(150);
|
||||
navigate("mobile-entry-two", "#mobile-comment-1");
|
||||
expect(container.firstElementChild).toBe(thread);
|
||||
expect(window.scrollY).toBe(500);
|
||||
navigate("mobile-entry-one", "#mobile-comment-0", true);
|
||||
expect(window.scrollY).toBe(150);
|
||||
navigate("mobile-entry-one", "#mobile-comment-1", true);
|
||||
expect(window.scrollY).toBe(500);
|
||||
});
|
||||
|
||||
it("owns browser restoration only while the mobile thread is enabled", () => {
|
||||
window.history.scrollRestoration = "auto";
|
||||
render(0);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
render(0, false);
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("scrolls the window to the bottom on mount", () => {
|
||||
render(0);
|
||||
expect(scrollToCalls).toContain(2000);
|
||||
});
|
||||
|
||||
it("follows content growth while pinned to the bottom", async () => {
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toContain(2000);
|
||||
});
|
||||
|
||||
it("holds position when the user has scrolled up", async () => {
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(100); // far from the bottom
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows document growth while pinned on mobile", async () => {
|
||||
let triggerResize = () => {};
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
triggerResize = () =>
|
||||
callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(1200);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2300);
|
||||
triggerResize();
|
||||
|
||||
expect(scrollToCalls).toContain(2300);
|
||||
});
|
||||
|
||||
it("holds document position through mobile composer growth when scrolled up", async () => {
|
||||
let triggerResize = () => {};
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
triggerResize = () =>
|
||||
callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(100);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2300);
|
||||
triggerResize();
|
||||
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows a late thread resize without a React content update or body resize", async () => {
|
||||
const observed = new Set<Element>();
|
||||
let resizeThread = () => {};
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeThread = () => {
|
||||
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
|
||||
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
};
|
||||
}
|
||||
observe(element: Element) { observed.add(element); }
|
||||
disconnect() { observed.clear(); }
|
||||
});
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
resizeThread();
|
||||
|
||||
expect(scrollToCalls).toContain(2400);
|
||||
expect(window.scrollY).toBe(1600);
|
||||
});
|
||||
|
||||
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
|
||||
expect(scrollToCalls).toContain(2400);
|
||||
});
|
||||
|
||||
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
await scrollWindowTo(900);
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
|
||||
await scrollWindowTo(1600);
|
||||
geometry.setScrollHeight(2700);
|
||||
render(2);
|
||||
expect(scrollToCalls).toContain(2700);
|
||||
});
|
||||
|
||||
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
|
||||
render(0);
|
||||
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(window.scrollY).toBe(1400);
|
||||
|
||||
await scrollWindowTo(500);
|
||||
scrollToCalls = [];
|
||||
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing when disabled", () => {
|
||||
render(0, false);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
render(1, false);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useTaskChatScrollNavigation } from "./scroll-navigation";
|
||||
|
||||
const PIN_THRESHOLD_PX = 48;
|
||||
|
||||
function scrollingElement(): Element | null {
|
||||
return document.scrollingElement ?? document.documentElement;
|
||||
}
|
||||
|
||||
function windowPinned(): boolean {
|
||||
const el = scrollingElement();
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - window.scrollY - window.innerHeight <= PIN_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function scrollWindowToBottom(): void {
|
||||
const el = scrollingElement();
|
||||
if (!el) return;
|
||||
window.scrollTo({ top: el.scrollHeight, left: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-scroll counterpart of TaskMessageScroller's auto-follow rule, for the
|
||||
* mobile document-flow thread (the mobile app shell scrolls the document, not
|
||||
* an inner viewport — see Layout.tsx). While the window is pinned to the
|
||||
* bottom (within a small threshold) content growth follows with INSTANT
|
||||
* scroll; once the user scrolls up we hold their position.
|
||||
*
|
||||
* The conversation enables this hook after navigation has settled and before
|
||||
* its coordinated reveal, so initial positioning happens before paint.
|
||||
*/
|
||||
export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void {
|
||||
const pinnedRef = useRef(true);
|
||||
const lastScrollYRef = useRef(window.scrollY);
|
||||
const navigation = useTaskChatScrollNavigation();
|
||||
const initialPositionApplied = useRef(false);
|
||||
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
|
||||
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
|
||||
const rememberAnchor = () => {
|
||||
lastScrollYRef.current = window.scrollY;
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (root) anchorRef.current = readThreadScrollAnchor(root, 0, window.innerHeight);
|
||||
if (initialPositionApplied.current) navigation.remember(window.scrollY, anchorRef.current);
|
||||
};
|
||||
const reconcile = () => {
|
||||
if (pinnedRef.current) scrollWindowToBottom();
|
||||
else {
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (root) {
|
||||
const delta = threadScrollAnchorDelta(root, anchorRef.current, 0);
|
||||
if (delta) window.scrollTo({ top: window.scrollY + delta, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
rememberAnchor();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onScroll = () => {
|
||||
// Content or viewport growth can leave a gap below the old bottom before
|
||||
// ResizeObserver runs. Only an upward scroll gives up existing follow
|
||||
// intent; a scroll event at the same position must not strand the output.
|
||||
pinnedRef.current = windowPinned() || (pinnedRef.current && window.scrollY >= lastScrollYRef.current);
|
||||
rememberAnchor();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", reconcile);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", reconcile);
|
||||
};
|
||||
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || typeof ResizeObserver === "undefined") return;
|
||||
let previousScrollHeight = scrollingElement()?.scrollHeight ?? null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextScrollHeight = scrollingElement()?.scrollHeight ?? null;
|
||||
if (
|
||||
nextScrollHeight == null ||
|
||||
nextScrollHeight === previousScrollHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
previousScrollHeight = nextScrollHeight;
|
||||
reconcile();
|
||||
});
|
||||
// The mobile body is viewport-sized even when its contents overflow. Watch
|
||||
// the thread box too, so streamed rows, images and expanding tools follow
|
||||
// after their own layout changes without needing a new parent content key.
|
||||
observer.observe(document.body);
|
||||
const thread = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (thread) observer.observe(thread);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
// Follow new content only when already pinned; otherwise hold position.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
|
||||
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
|
||||
initialPositionApplied.current = false;
|
||||
}
|
||||
if (navigation.ready && !initialPositionApplied.current) {
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
const top = root ? navigation.initialPosition(root, 0, window.scrollY) : null;
|
||||
if (top !== null) {
|
||||
window.scrollTo({ top, behavior: "auto" });
|
||||
pinnedRef.current = windowPinned();
|
||||
rememberAnchor();
|
||||
}
|
||||
initialPositionApplied.current = true;
|
||||
}
|
||||
reconcile();
|
||||
}, [contentKey, enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
// One owner for document-flow compensation, as on the desktop viewport.
|
||||
const previousRestoration = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = "manual";
|
||||
document.documentElement.classList.add("task-chat-window-scroll");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("task-chat-window-scroll");
|
||||
window.history.scrollRestoration = previousRestoration;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/** Mount below TaskChatScrollReady so mobile navigation also waits for targets
|
||||
* fetched after the conversation's first reveal. */
|
||||
export function TaskChatWindowScroll({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
|
||||
useWindowAutoFollow(contentKey, enabled);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useMobileVisualViewport } from "./useMobileVisualViewport";
|
||||
|
||||
function Host({ mobile = true, conversation = true }: { mobile?: boolean; conversation?: boolean }) {
|
||||
const ref = useMobileVisualViewport(mobile);
|
||||
return <div ref={ref}><div data-task-chat-shell={conversation ? "" : undefined}>Chat shell</div></div>;
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let viewport: EventTarget & { height: number; offsetTop: number; scale: number };
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("innerHeight", 695);
|
||||
Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: 695 });
|
||||
viewport = Object.assign(new EventTarget(), { height: 695, offsetTop: 0, scale: 1 });
|
||||
vi.stubGlobal("visualViewport", viewport);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
Reflect.deleteProperty(document.documentElement, "clientHeight");
|
||||
document.documentElement.scrollTop = 0;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resizes the shell from the visible height when iOS innerHeight shrinks too", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
act(() => {
|
||||
vi.stubGlobal("innerHeight", 358);
|
||||
viewport.height = 358;
|
||||
viewport.offsetTop = 337;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
|
||||
expect(shell.dataset.keyboardOpen).toBe("true");
|
||||
// Panning must not translate or resize the shell a second time.
|
||||
act(() => { viewport.offsetTop = 185; viewport.dispatchEvent(new Event("scroll")); });
|
||||
expect(shell.style.top).toBe("");
|
||||
expect(shell.style.transform).toBe("");
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
|
||||
act(() => { viewport.height = 695; viewport.offsetTop = 0; viewport.dispatchEvent(new Event("resize")); });
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
|
||||
expect(shell.dataset.keyboardOpen).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves pinch zoom and leaves desktop sizing to CSS", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
act(() => { viewport.scale = 2; viewport.height = 350; viewport.dispatchEvent(new Event("resize")); });
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
|
||||
act(() => root.render(<Host mobile={false} />));
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("");
|
||||
expect(shell.dataset.keyboardOpen).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invalidate shell styles for typing, scrolling, or unchanged viewport events", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(shell, { attributes: true });
|
||||
act(() => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
shell.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
});
|
||||
expect(observer.takeRecords()).toHaveLength(0);
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
it("rejects Safari's stale document focus scroll after the keyboard has resized the conversation", () => {
|
||||
act(() => root.render(<Host />));
|
||||
act(() => {
|
||||
viewport.height = 358;
|
||||
// Some openings pan before the resize event; others pan one frame later.
|
||||
document.documentElement.scrollTop = 337;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
document.documentElement.scrollTop = 337;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves ordinary pages and inner message scrolling alone", () => {
|
||||
viewport.height = 358;
|
||||
act(() => root.render(<Host conversation={false} />));
|
||||
document.documentElement.scrollTop = 337;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(337);
|
||||
act(() => root.render(<Host />));
|
||||
const messages = container.querySelector('[data-task-chat-shell]') as HTMLElement;
|
||||
messages.scrollTop = 250;
|
||||
messages.dispatchEvent(new Event("scroll"));
|
||||
expect(messages.scrollTop).toBe(250);
|
||||
});
|
||||
|
||||
it("does not suppress zoom panning when the keyboard was already open", () => {
|
||||
viewport.height = 358;
|
||||
act(() => root.render(<Host />));
|
||||
viewport.scale = 2;
|
||||
viewport.height = 179;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
document.documentElement.scrollTop = 120;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(120);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
|
||||
/** Size the mobile conversation shell, never translate it with Safari's pan.
|
||||
* CSS owns the fixed shell and its flex layout; messages own their scrolling. */
|
||||
export function useMobileVisualViewport(enabled: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const shell = ref.current;
|
||||
const viewport = window.visualViewport;
|
||||
if (!enabled || !shell || !viewport) return;
|
||||
const restoreDocumentPosition = () => {
|
||||
if (viewport.scale !== 1 || shell.dataset.keyboardOpen !== "true" || !shell.querySelector("[data-task-chat-shell]")) return;
|
||||
const page = document.scrollingElement ?? document.documentElement;
|
||||
if (page.scrollTop !== 0) page.scrollTop = 0;
|
||||
};
|
||||
const update = () => {
|
||||
if (viewport.scale !== 1 || !Number.isFinite(viewport.height) || viewport.height <= 0) return;
|
||||
// Read geometry before writing styles, avoiding a forced layout after our
|
||||
// own height update. Nothing here subscribes to input or thread updates.
|
||||
const keyboardOpen = document.documentElement.clientHeight - viewport.height > 120;
|
||||
shell.style.setProperty("--mobile-chat-viewport-height", `${viewport.height}px`);
|
||||
// innerHeight can shrink with visualViewport on iOS, so it is not the
|
||||
// keyboard-closed baseline. The layout viewport remains stable.
|
||||
if (keyboardOpen) {
|
||||
shell.dataset.keyboardOpen = "true";
|
||||
} else {
|
||||
delete shell.dataset.keyboardOpen;
|
||||
}
|
||||
restoreDocumentPosition();
|
||||
};
|
||||
update();
|
||||
viewport.addEventListener("resize", update);
|
||||
window.addEventListener("resize", update);
|
||||
// Safari can apply its pre-keyboard focus pan AFTER the resize. The fixed
|
||||
// document is not a scroll surface. Listen without capture so scrolls in
|
||||
// the message list/editor never reach this fallback.
|
||||
document.addEventListener("scroll", restoreDocumentPosition);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", update);
|
||||
window.removeEventListener("resize", update);
|
||||
document.removeEventListener("scroll", restoreDocumentPosition);
|
||||
shell.style.removeProperty("--mobile-chat-viewport-height");
|
||||
delete shell.dataset.keyboardOpen;
|
||||
};
|
||||
}, [enabled]);
|
||||
return ref;
|
||||
}
|
||||
+58
-4
@@ -583,6 +583,8 @@
|
||||
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
|
||||
Desktop and the classic thread use the safe-area default. */
|
||||
--tc-composer-bottom: var(--sz-calc-8);
|
||||
--mobile-chat-viewport-height: 100dvh;
|
||||
--tc-composer-editor-max-height: min(var(--sz-28dvh), calc(var(--mobile-chat-viewport-height) * 0.4));
|
||||
--tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom));
|
||||
/* With the dock's pb-1, leave 12px below the card when the nav hides. */
|
||||
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
|
||||
@@ -887,6 +889,62 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
|
||||
transition: bottom var(--mobile-nav-motion-duration, var(--motion-mobile-nav-enter)) var(--mobile-nav-motion-ease, var(--motion-ease-out-expo));
|
||||
}
|
||||
|
||||
/* Mobile conversations use the same single message scroller + normal footer as
|
||||
desktop. Opt in from the rendered task shell, so classic tasks, lists, and
|
||||
the empty agent-chat picker retain their existing document layout. */
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body,
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--mobile-chat-viewport-height);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) :is(.app-main-row, .app-content-row) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-content-column {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-breadcrumb {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) #main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--tc-composer-visible-nav-offset);
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"][data-keyboard-open="true"]:has([data-task-chat-shell]) #main-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-mobile-nav-enter: 0ms;
|
||||
@@ -3292,10 +3350,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.task-chat-window-scroll {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.task-chat-loading-shell .animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -7882,11 +7882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
|
||||
// auto height) — the thread renders in normal flow (PAP-360).
|
||||
"flex w-full flex-col gap-6"
|
||||
: // Fill main exactly so the outer page never scrolls — the
|
||||
? // Fill main exactly so the outer page never scrolls — the
|
||||
// thread's own viewport is the only scroll surface.
|
||||
// Keep status banners close to the transcript. A full section
|
||||
// gap here shortens the pinned message viewport enough to
|
||||
@@ -8180,9 +8176,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onValueChange={setDetailTab}
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? undefined
|
||||
: "min-h-0 flex-1"
|
||||
? "min-h-0 flex-1"
|
||||
: "space-y-3"
|
||||
}
|
||||
>
|
||||
@@ -8222,8 +8216,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? streamlinedTaskDetailEnabled
|
||||
? undefined
|
||||
: "-mx-4"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "-mx-4 flex min-h-0 flex-1 flex-col"
|
||||
: streamlinedTaskDetailEnabled
|
||||
? "flex min-h-0 flex-col"
|
||||
: "-mx-4 -mt-4 md:-mx-6 md:-mt-6 flex min-h-0 flex-col"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
|
||||
import { useMobileVisualViewport } from "@/hooks/useMobileVisualViewport";
|
||||
|
||||
function StreamingResponse({ running }: { running: boolean }) {
|
||||
const [lines, setLines] = useState(16);
|
||||
@@ -13,7 +13,7 @@ function StreamingResponse({ running }: { running: boolean }) {
|
||||
const timer = window.setTimeout(() => setLines(lines + 1), 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [running, lines]);
|
||||
return <TaskChatThreadView scroll={false} items={[
|
||||
return <TaskChatThreadView items={[
|
||||
{ id: "request", kind: "message", author: "human", text: "Give me a detailed progress report as you work." },
|
||||
{
|
||||
id: "response", kind: "message", author: "agent", authorName: "Researcher", streaming: running,
|
||||
@@ -24,21 +24,19 @@ function StreamingResponse({ running }: { running: boolean }) {
|
||||
|
||||
function MobileFollow() {
|
||||
const [running, setRunning] = useState(true);
|
||||
// The child updates independently, exercising late layout growth as well as
|
||||
// normal streaming without relying on a parent content-key notification.
|
||||
useWindowAutoFollow(0, true);
|
||||
return <div className="flex min-h-dvh flex-col bg-background text-foreground">
|
||||
<header className="flex h-12 shrink-0 items-center px-4 text-sm font-medium">Streaming progress report</header>
|
||||
<main className="flex flex-1 flex-col p-4 pb-(--tc-composer-visible-nav-offset)"
|
||||
style={{ "--tc-composer-bottom": "var(--tc-composer-visible-nav-offset)" } as CSSProperties}>
|
||||
<div data-testid="task-chat-thread" className="-mx-4 flex flex-col">
|
||||
const viewportRef = useMobileVisualViewport(true);
|
||||
return <div ref={viewportRef} data-mobile="true" className="paperclip-app-shell flex min-h-dvh flex-col bg-background text-foreground">
|
||||
<div className="app-main-row"><div className="app-content-column flex flex-col">
|
||||
<header className="app-breadcrumb flex h-12 shrink-0 items-center px-4 text-sm font-medium">iOS layout — focus-scroll correction</header>
|
||||
<div className="app-content-row"><main id="main-content" className="flex flex-1 flex-col p-4">
|
||||
<div data-task-chat-shell="" data-testid="task-chat-thread" className="-mx-4 flex min-h-0 flex-1 flex-col">
|
||||
<StreamingResponse running={running} />
|
||||
<TaskChatComposerDock mobile streamlined={false}>
|
||||
<TaskChatComposer mobile workMode="standard" onAdd={async () => setRunning(true)}
|
||||
onStop={running ? async () => setRunning(false) : undefined} />
|
||||
</TaskChatComposerDock>
|
||||
</div>
|
||||
</main>
|
||||
</main></div></div></div>
|
||||
<MobileBottomNav visible />
|
||||
</div>;
|
||||
}
|
||||
@@ -56,7 +54,7 @@ const meta = {
|
||||
],
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
|
||||
"notes": "Production mobile viewport sizing, message scroller, and composer with bounded synthetic streaming. Physical iPhone keyboard acceptance is required."
|
||||
},
|
||||
},
|
||||
globals: { theme: "light", viewport: { value: "mobile", isRotated: false } },
|
||||
|
||||
Reference in new issue
Block a user