diff --git a/ui/src/components/task-chat/useWindowAutoFollow.test.tsx b/ui/src/components/task-chat/useWindowAutoFollow.test.tsx index ba43d080b98..4cfde316a28 100644 --- a/ui/src/components/task-chat/useWindowAutoFollow.test.tsx +++ b/ui/src/components/task-chat/useWindowAutoFollow.test.tsx @@ -8,7 +8,7 @@ import { TaskChatScrollNavigation } from "./scroll-navigation"; function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) { useWindowAutoFollow(contentKey, enabled); - return
thread
; + return
thread
; } /** @@ -189,6 +189,71 @@ describe("useWindowAutoFollow", () => { expect(scrollToCalls).toHaveLength(0); }); + it("follows a late thread resize without a React content update or body resize", async () => { + const observed = new Set(); + 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); diff --git a/ui/src/components/task-chat/useWindowAutoFollow.ts b/ui/src/components/task-chat/useWindowAutoFollow.ts index df17a1dcfcd..d971e94e42f 100644 --- a/ui/src/components/task-chat/useWindowAutoFollow.ts +++ b/ui/src/components/task-chat/useWindowAutoFollow.ts @@ -32,11 +32,13 @@ function scrollWindowToBottom(): void { */ 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(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); @@ -56,16 +58,22 @@ export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void useEffect(() => { if (!enabled) return; const onScroll = () => { - pinnedRef.current = windowPinned(); + // 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 }); - return () => window.removeEventListener("scroll", onScroll); + 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; - const observed = document.body; let previousScrollHeight = scrollingElement()?.scrollHeight ?? null; const observer = new ResizeObserver(() => { const nextScrollHeight = scrollingElement()?.scrollHeight ?? null; @@ -78,7 +86,12 @@ export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void previousScrollHeight = nextScrollHeight; reconcile(); }); - observer.observe(observed); + // 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]); diff --git a/ui/storybook/stories/task-chat-mobile-follow.stories.tsx b/ui/storybook/stories/task-chat-mobile-follow.stories.tsx new file mode 100644 index 00000000000..3dbcb225648 --- /dev/null +++ b/ui/storybook/stories/task-chat-mobile-follow.stories.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState, type CSSProperties } 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"; + +function StreamingResponse({ running }: { running: boolean }) { + const [lines, setLines] = useState(16); + useEffect(() => { + if (!running) return; + const timer = window.setInterval(() => setLines((count) => Math.min(count + 1, 256)), 500); + return () => window.clearInterval(timer); + }, [running]); + return `Update ${index + 1}: I checked the next section and recorded the results for the final report.`).join("\n\n"), + }, + ]} />; +} + +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
+
Streaming progress report
+
+
+ + + setRunning(true)} + onStop={running ? async () => setRunning(false) : undefined} /> + +
+
+ +
; +} + +const meta = { + title: "Task chat/Mobile streaming follow", + component: MobileFollow, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Controlled output in the production thread and composer. At the bottom, updates stay visible. Scroll up to hold your reading position, then return to the bottom to resume following. Stop pauses the fixture; send a message to resume. No agent or provider calls." } }, + }, + globals: { theme: "light", viewport: { value: "mobile", isRotated: false } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Streaming: Story = {};