mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(ui): keep mobile task output following above the composer
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6c36c07a4f
commit
915ddcf35f
3 files changed
+140
-5
No files matched your search
@@ -8,7 +8,7 @@ import { TaskChatScrollNavigation } from "./scroll-navigation";
|
||||
|
||||
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
|
||||
useWindowAutoFollow(contentKey, enabled);
|
||||
return <div>thread</div>;
|
||||
return <div data-testid="task-chat-thread">thread</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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<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);
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 <TaskChatThreadView scroll={false} 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,
|
||||
text: Array.from({ length: lines }, (_, index) => `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 <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">
|
||||
<StreamingResponse running={running} />
|
||||
<TaskChatComposerDock mobile streamlined={false}>
|
||||
<TaskChatComposer mobile workMode="standard" onAdd={async () => setRunning(true)}
|
||||
onStop={running ? async () => setRunning(false) : undefined} />
|
||||
</TaskChatComposerDock>
|
||||
</div>
|
||||
</main>
|
||||
<MobileBottomNav visible />
|
||||
</div>;
|
||||
}
|
||||
|
||||
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<typeof MobileFollow>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Streaming: Story = {};
|
||||
Reference in new issue
Block a user