diff --git a/docs/agent-stream-disclosure.md b/docs/agent-stream-disclosure.md index 9cde8a44ff..1d5caefcf2 100644 --- a/docs/agent-stream-disclosure.md +++ b/docs/agent-stream-disclosure.md @@ -1,8 +1,8 @@ # Agent stream disclosures Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group, -a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the -first painted frame. No animation. +a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the +tapped trigger at the same screen y in the first painted frame. No animation. ## The contract @@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value)); ``` `view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's -`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row, -`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. +`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a +timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. +A toggle that calls `setState` directly skips the hold: on Android the expanded content paints +over the next row for a frame before the list learns the new size. + +`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in +place. Header triggers sit on the top edge, the default. A long user message's show more sits under +the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks +above the trigger, and the rows below stay put. Following output: -- A hold always detaches the bottom anchor controller (`holdForDisclosure`). +- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on + native while following: there the end pin already keeps the row's bottom in place. - When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes @@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference. ## Web -`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores -the row's top after layout, before paint (`strategy-web.tsx`). +`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading +reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the +scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the +content past the offset, before the reconcile reads it. Restoring from the clamped offset would +apply the shrink twice. ## Native On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do -not hold. Summary lines and todo cards expand inline. +not hold. Summary lines, todo cards, plan cards and show more expand inline. The timeline is a LegendList in forward order: one `data` array of history rows followed by the live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size @@ -51,14 +62,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn. One owner keeps the scroll offset at any moment: -| State | Owner | -| ---------------------- | ----------------------------------------------------------------------------------------- | -| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | -| Following the end | The end pin (`native-end-pin.tsx`). | -| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. | -| User scroll | The finger. The controller detaches at drag start. | -| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | -| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | +| State | Owner | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | +| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. | +| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. | +| User scroll | The finger. The controller detaches at drag start. | +| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | +| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | The end pin is a zero-height view at the bottom of the content and the content's first native child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The @@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles. ### The hold -`holdRow(rowId, apply)` runs in the toggle's event: +`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event: -1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and - LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row - (`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible - one down, and a size change with no data change keeps the first row in view whatever +1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row + tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row + below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away + and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row + (`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin + keeps the content end, and the row's bottom with it, in the frame the new height mounts. + + A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no + row below it: its bottom is the content end (the running indicator is the list footer, not a + row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin + anchors only once it is mounted, and a pin that mounts in the same commit as the new height + anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s + commit mounts with the pin already in place. The pin keeps the distance from the end, and each + content size change clamps it to the scroll room left, so the hold re-sticks if the list was + at its end when tapped or a collapse used up the room. The release tracks that distance + instead of reading the offset: the new content size reaches JS before the pin's scroll does. + A reply landing below the row ends the pin in the commit that mounts the reply, so the reply + grows below the row and the trigger stays where it was. A reply that lands before `apply` runs + moves the hold to the reply's row. + + Upstream LegendList anchors only rows from the first fully visible one down to the last fully + visible one, and a size change with no data change keeps the first row in view whatever `shouldRestorePosition` says. A row cut off at the top would then let the row below it hold - instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off - at the top as in view and applies `shouldRestorePosition` to size changes too. + instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList + patch counts the rows cut off at the top, and the row just past the bottom, as in view and + applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp + prediction: the scroll view clamps after the adjustment + ([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near + the end to the end. + 2. Run `apply`. 3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so LegendList's container does not re-render and would learn the new height from `onLayout` a task @@ -130,7 +164,7 @@ With every turn collapsed, flinging from a cold open into history did not blank ### LegendList patch -`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists +`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists them by purpose, and [legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream. Drop a hunk when the installed LegendList ships its fix. diff --git a/packages/app/src/agent-stream/disclosure.browser.test.tsx b/packages/app/src/agent-stream/disclosure.browser.test.tsx new file mode 100644 index 0000000000..74c61c4988 --- /dev/null +++ b/packages/app/src/agent-stream/disclosure.browser.test.tsx @@ -0,0 +1,147 @@ +import React, { act, createRef, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { within } from "@testing-library/dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n as testI18n } from "@/i18n/i18next"; +import { PlanCard } from "@/components/plan-card"; +import { MessagePreview } from "@/components/user-message"; +import { DisclosureRow } from "./disclosure"; +import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy"; + +// Load translations so the toggles expose their real accessible names. +void testI18n; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +let root: Root; +let container: HTMLDivElement; +// The plan body renders through the app's Markdown renderer, which reads through React Query. +let queryClient: QueryClient; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); +}); + +afterEach(() => { + act(() => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +interface Hold { + rowId: string; + triggerEdge: DisclosureTriggerEdge; + /** Whether the expanded content was already on screen when the strategy received the hold. */ + appliedBefore: boolean; +} + +/** + * A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so + * a toggle that skips the hold still changes state but leaves no record. + */ +function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) { + const holds: Hold[] = []; + const viewport: StreamViewportHandle = { + scrollToBottom: vi.fn(), + prepareForViewportChange: vi.fn(), + holdRow: (heldRowId, apply, triggerEdge) => { + holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() }); + apply(); + }, + }; + const viewportRef = createRef(); + viewportRef.current = viewport; + act(() => + root.render( + + + {children} + + , + ), + ); + return holds; +} + +const view = () => within(container); + +const preLine = { whiteSpace: "pre-line" } as const; + +async function waitForLayout() { + await act(async () => { + for (let frame = 0; frame < 2; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }); +} + +describe("timeline disclosures hold their row", () => { + it("routes user message show more and show less through the row's hold", async () => { + const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n"); + const holds = mountRow( + "message-1", + () => view().queryByRole("button", { name: "Show less" }) !== null, + + {longText} + , + ); + // The preview learns it overflows from its first layout. + await waitForLayout(); + + act(() => view().getByRole("button", { name: "Show more" }).click()); + expect(view().getByRole("button", { name: "Show less" })).toBeTruthy(); + + act(() => view().getByRole("button", { name: "Show less" }).click()); + expect(view().getByRole("button", { name: "Show more" })).toBeTruthy(); + + expect(holds).toEqual([ + // Show more sits under the text it opens. + { rowId: "message-1", triggerEdge: "bottom", appliedBefore: false }, + { rowId: "message-1", triggerEdge: "bottom", appliedBefore: true }, + ]); + }); + + it("routes plan card collapse and expand through the row's hold", () => { + const header = () => view().getByRole("button", { name: "Approved plan" }); + const isExpanded = () => header().getAttribute("aria-expanded") === "true"; + const holds = mountRow( + "plan-1", + isExpanded, + , + ); + + act(() => header().click()); + expect(isExpanded()).toBe(false); + + act(() => header().click()); + expect(isExpanded()).toBe(true); + + expect(holds).toEqual([ + { rowId: "plan-1", triggerEdge: "top", appliedBefore: true }, + { rowId: "plan-1", triggerEdge: "top", appliedBefore: false }, + ]); + }); + + it("toggles a plan card outside a timeline row, as the pending permission card renders", () => { + act(() => + root.render( + + + , + ), + ); + const header = () => view().getByRole("button", { name: "Plan" }); + const isExpanded = () => header().getAttribute("aria-expanded") === "true"; + + act(() => header().click()); + expect(isExpanded()).toBe(false); + + act(() => header().click()); + expect(isExpanded()).toBe(true); + }); +}); diff --git a/packages/app/src/agent-stream/disclosure.tsx b/packages/app/src/agent-stream/disclosure.tsx index b35e67e567..2deb42554f 100644 --- a/packages/app/src/agent-stream/disclosure.tsx +++ b/packages/app/src/agent-stream/disclosure.tsx @@ -1,11 +1,15 @@ import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react"; -import type { StreamViewportHandle } from "./strategy"; +import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy"; /** - * Runs a disclosure's state change. Inside a timeline row, the row that owns the - * trigger keeps its screen position through the change; elsewhere `apply` runs as is. + * Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen + * position through the change; elsewhere `apply` runs as is. A trigger below the content it + * opens passes `triggerEdge: "bottom"`. */ -export type DisclosureHold = (apply: () => void) => void; +export type DisclosureHold = ( + apply: () => void, + options?: { triggerEdge?: DisclosureTriggerEdge }, +) => void; const applyInPlace: DisclosureHold = (apply) => apply(); @@ -21,10 +25,10 @@ export function DisclosureRow({ children: ReactNode; }) { const hold = useMemo( - () => (apply) => { + () => (apply, options) => { const viewport = viewportRef.current; if (viewport) { - viewport.holdRow(rowId, apply); + viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top"); } else { apply(); } diff --git a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx index a16763f467..338fa84008 100644 --- a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -3,6 +3,7 @@ import React, { act, useSyncExternalStore } from "react"; import { createRoot, type Root } from "react-dom/client"; import { View } from "react-native"; import { afterEach, beforeEach, expect, it } from "vitest"; +import { disclosureAnchor } from "./native-disclosure-hold"; // The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch). // A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row. @@ -130,7 +131,16 @@ function mountList(heldRowId: string) { return position - state.scroll; }; const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId); - return { render, scrollTo, heldRowTop, rowSize }; + const rowBottom = (rowId: string) => { + const state = listRef.current?.getState(); + const position = state?.positionByKey(rowId); + const size = state?.sizes.get(rowId); + if (state === undefined || position === undefined || size === undefined) { + throw new Error(`Row ${rowId} is not laid out`); + } + return position + size - state.scroll; + }; + return { render, scrollTo, heldRowTop, rowSize, rowBottom }; } it("anchors a data change on a held row whose top is above the viewport", async () => { @@ -171,6 +181,72 @@ it("anchors a size change on a held row whose top is above the viewport", async expect(list.heldRowTop()).toBe(-20); }); +// A long message's show more sits under the text it opens, on the bottom edge of its row. The +// strategy anchors the row below it, whose top is that bottom edge. +const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({ + id: `row-${index}`, + height: index === 3 ? 60 : 40, +})); + +function mountBottomTriggerHold(rowId: string) { + const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom"); + if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`); + return mountList(anchor.rowId); +} + +it("keeps the bottom of a row whose trigger sits under its growing content", async () => { + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(100); + const triggerBottom = list.rowBottom("row-3"); + expect(triggerBottom).toBe(80); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); +}); + +it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => { + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(140); + const triggerBottom = list.rowBottom("row-3"); + expect(triggerBottom).toBe(40); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(triggerBottom); +}); + +it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => { + // In the app the row ends with the message's timestamp under the trigger, so a trigger near the + // bottom of the viewport has its row's bottom, and the row below, out of view. + const list = mountBottomTriggerHold("row-3"); + list.render(bottomTriggerRows); + await wait(150); + await list.scrollTo(40); + const rowBottom = list.rowBottom("row-3"); + expect(rowBottom).toBe(140); + + act(() => growRow("row-3", 260)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(rowBottom); + + act(() => growRow("row-3", 60)); + await wait(150); + expect(list.rowBottom("row-3")).toBe(rowBottom); +}); + it("keeps measured row sizes while the list is not laid out", async () => { // A hidden retained tab is display: none. Its rows still render, and a row measured there // reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout diff --git a/packages/app/src/agent-stream/native-disclosure-hold.test.ts b/packages/app/src/agent-stream/native-disclosure-hold.test.ts index b732edbb01..507ca07700 100644 --- a/packages/app/src/agent-stream/native-disclosure-hold.test.ts +++ b/packages/app/src/agent-stream/native-disclosure-hold.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createNativeDisclosureHold } from "./native-disclosure-hold"; +import { + createNativeDisclosureHold, + disclosureAnchor, + createEndHoldDistance, + type DisclosureAnchor, +} from "./native-disclosure-hold"; function createFrames() { let nextId = 1; @@ -39,22 +44,22 @@ describe("native disclosure hold", () => { it("holds the row until two frames pass without an item size change", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); - expect(hold.heldRowId()).toBe("turn-1"); + expect(hold.anchorRowId()).toBe("turn-1"); expect(releases).toEqual([]); frames.runFrame(); - expect(hold.heldRowId()).toBeNull(); + expect(hold.anchorRowId()).toBeNull(); expect(releases).toEqual(["released"]); }); it("restarts the quiet frames on every item size change", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); hold.noteItemSizeChanged(); frames.runFrame(); @@ -79,11 +84,11 @@ describe("native disclosure hold", () => { it("moves the hold to the latest toggled row and releases once", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); frames.runFrame(); - hold.hold("turn-2"); + hold.hold({ kind: "row", rowId: "turn-2" }); - expect(hold.heldRowId()).toBe("turn-2"); + expect(hold.anchorRowId()).toBe("turn-2"); frames.runFrame(); frames.runFrame(); @@ -94,12 +99,137 @@ describe("native disclosure hold", () => { it("drops the hold without releasing when cancelled", () => { const { frames, hold, releases } = createHarness(); - hold.hold("turn-1"); + hold.hold({ kind: "row", rowId: "turn-1" }); hold.cancel(); frames.runFrame(); frames.runFrame(); - expect(hold.heldRowId()).toBeNull(); + expect(hold.anchorRowId()).toBeNull(); expect(releases).toEqual([]); }); + + it("holds the content end until two quiet frames pass, with no row to anchor on", () => { + const { frames, hold, releases } = createHarness(); + + hold.hold({ kind: "end" }); + frames.runFrame(); + hold.noteItemSizeChanged(); + frames.runFrame(); + + expect(hold.anchorRowId()).toBeNull(); + expect(releases).toEqual([]); + + frames.runFrame(); + + expect(releases).toEqual(["released"]); + }); +}); + +describe("next-frame disclosure hold", () => { + it("anchors the row a reply landed as while the toggle waited for its frame", () => { + const { frames, hold } = createHarness(); + let anchor: DisclosureAnchor = { kind: "end" }; + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => anchor, + (resolved) => applied.push(resolved), + ); + expect(applied).toEqual([]); + + anchor = { kind: "row", rowId: "turn-2" }; + frames.runFrame(); + + expect(hold.anchorRowId()).toBe("turn-2"); + expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]); + }); + + it("still lands the toggle when the hold is cancelled before its frame", () => { + const { frames, hold, releases } = createHarness(); + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => ({ kind: "end" }), + (resolved) => applied.push(resolved), + ); + hold.cancel(); + frames.runFrame(); + frames.runFrame(); + + expect(applied).toEqual([{ kind: "end" }]); + expect(releases).toEqual([]); + }); + + it("lands a waiting toggle before the next hold starts", () => { + const { frames, hold } = createHarness(); + const applied: DisclosureAnchor[] = []; + + hold.holdNextFrame( + () => ({ kind: "end" }), + (resolved) => applied.push(resolved), + ); + hold.hold({ kind: "row", rowId: "turn-1" }); + + expect(applied).toEqual([{ kind: "end" }]); + frames.runFrame(); + expect(applied).toEqual([{ kind: "end" }]); + expect(hold.anchorRowId()).toBe("turn-1"); + }); +}); + +describe("disclosure anchor", () => { + const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }]; + + it("keeps the top of a row whose trigger sits on its top edge", () => { + expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" }); + }); + + it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => { + expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({ + kind: "row", + rowId: "turn-1", + }); + }); + + // A prompt the agent has not answered yet is the last row; the running indicator is the footer. + it("keeps the content end for a bottom-edge trigger on the last row", () => { + expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" }); + }); + + // The strategy re-derives this each render, so a reply lets go of the end in the commit it lands. + it("stops keeping the content end once a reply lands below the row", () => { + expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({ + kind: "row", + rowId: "turn-2", + }); + }); +}); + +describe("end hold distance", () => { + const viewportHeight = 600; + + it("keeps the distance from the end the toggle started at", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 2000, viewportHeight }); + + expect(distance.current()).toBe(20); + }); + + it("shrinks to the scroll room a collapse leaves", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 610, viewportHeight }); + + expect(distance.current()).toBe(10); + }); + + it("stays at the end when output grows the content after a collapse reached the end", () => { + const distance = createEndHoldDistance(20); + + distance.noteContentSize({ contentHeight: 600, viewportHeight }); + distance.noteContentSize({ contentHeight: 700, viewportHeight }); + + expect(distance.current()).toBe(0); + }); }); diff --git a/packages/app/src/agent-stream/native-disclosure-hold.ts b/packages/app/src/agent-stream/native-disclosure-hold.ts index 96b5087b98..7dc7a55bd0 100644 --- a/packages/app/src/agent-stream/native-disclosure-hold.ts +++ b/packages/app/src/agent-stream/native-disclosure-hold.ts @@ -1,13 +1,30 @@ +import type { DisclosureTriggerEdge } from "./strategy"; + +/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */ +export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" }; + /** - * The native viewport's disclosure hold. While a row is held, it is the only row the list may - * anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list - * measures the toggled row and any rows it inserted over one or more frames, and end following - * must not resume between the change and the list's last layout correction. + * The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the + * list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for + * `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more + * frames, and end following must not resume between the change and the list's last layout + * correction. */ export interface NativeDisclosureHold { - heldRowId(): string | null; - hold(rowId: string): void; + /** The row the list may anchor on; null outside a hold and while the hold keeps the end. */ + anchorRowId(): string | null; + hold(anchor: DisclosureAnchor): void; + /** + * Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit + * already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits. + * A later hold or a cancel lands a waiting toggle right away. + */ + holdNextFrame( + resolveAnchor: () => DisclosureAnchor, + apply: (anchor: DisclosureAnchor) => void, + ): void; noteItemSizeChanged(): void; + /** Drops the hold without releasing it. */ cancel(): void; } @@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: { cancelFrame: (id: number) => void; onRelease: () => void; }): NativeDisclosureHold { - let heldRowId: string | null = null; + let anchor: DisclosureAnchor | null = null; let frameId: number | null = null; let remainingFrames = 0; + let waitingToggle: { frameId: number; land: () => void } | null = null; const stopCountdown = () => { if (frameId !== null) { @@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: { return; } frameId = null; - heldRowId = null; + anchor = null; input.onRelease(); }; @@ -45,20 +63,80 @@ export function createNativeDisclosureHold(input: { frameId = input.requestFrame(tick); }; + const landWaitingToggle = () => { + if (!waitingToggle) return; + const { frameId: waitingFrameId, land } = waitingToggle; + waitingToggle = null; + input.cancelFrame(waitingFrameId); + land(); + }; + + const startHold = (nextAnchor: DisclosureAnchor) => { + anchor = nextAnchor; + restartCountdown(); + }; + return { - heldRowId: () => heldRowId, - hold(rowId) { - heldRowId = rowId; - restartCountdown(); + anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null), + hold(nextAnchor) { + landWaitingToggle(); + startHold(nextAnchor); + }, + holdNextFrame(resolveAnchor, apply) { + landWaitingToggle(); + const waitingFrameId = input.requestFrame(() => { + waitingToggle = null; + const nextAnchor = resolveAnchor(); + startHold(nextAnchor); + apply(nextAnchor); + }); + waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) }; }, noteItemSizeChanged() { - if (heldRowId !== null) { + if (anchor !== null) { restartCountdown(); } }, cancel() { + landWaitingToggle(); stopCountdown(); - heldRowId = null; + anchor = null; }, }; } + +/** + * What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so + * a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled + * row's bottom. The last row's bottom is the end of the content. + */ +export function disclosureAnchor( + rows: readonly { id: string }[], + rowId: string, + triggerEdge: DisclosureTriggerEdge, +): DisclosureAnchor { + if (triggerEdge === "top") return { kind: "row", rowId }; + const index = rows.findIndex((row) => row.id === rowId); + const below = rows[index + 1]; + return below ? { kind: "row", rowId: below.id } : { kind: "end" }; +} + +/** + * The list's distance from the end during an end hold. The end pin keeps the distance through every + * content size change, and the scroll view clamps it to the scroll room the content has at that + * moment. A clamp is permanent: content that grows afterwards does not bring the distance back. + */ +export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance { + let distance = distanceAtToggle; + return { + noteContentSize({ contentHeight, viewportHeight }) { + distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight)); + }, + current: () => distance, + }; +} + +export interface EndHoldDistance { + noteContentSize(input: { contentHeight: number; viewportHeight: number }): void; + current(): number; +} diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 30d30ab1a0..a726203fc2 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -7,7 +7,7 @@ describe("reading anchor", () => { anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); const replacements = new Map([["tool", "prompt"]]); expect(anchor.getRowId(replacements)).toBe("prompt"); - expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000); // Projection does not consume the correction; an unmounted host keeps the pin. expect(anchor.reconcile(0, [], false, replacements)).toBe(0); expect(anchor.getRowId()).toBe("tool"); @@ -59,13 +59,32 @@ describe("reading anchor", () => { it("keeps a pinned row in place instead of the row under the reading line", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); - anchor.pin(rows[2]!); + anchor.pin(rows[2]!, "top", 600); // The row being read grows; only the pinned row below it holds its position. expect( anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]), ).toBe(900); }); + it("keeps the bottom of a row pinned by a trigger below its content", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.pin(rows[2]!, "bottom", 600); + // The pinned row grows downward; its bottom, where the trigger sits, holds its position. + const grown = { ...rows[2]!, height: rows[2]!.height + 300 }; + expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900); + }); + + it("measures a pinned row's correction from the offset at the toggle", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(900, rows); + anchor.pin(rows[2]!, "bottom", 900); + // The row shrinks and the browser clamps the offset to the shorter content before the + // reconcile reads it. + const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 }; + expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600); + }); + it("leaves expansion of the row being read in place", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); @@ -109,8 +128,8 @@ describe("reading anchor", () => { it("projects a prepend range without consuming the correction before layout commits", () => { const anchor = createReadingAnchor(); anchor.reconcile(600, rows); - expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); - expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440); expect( anchor.reconcile( 440, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index ed2baa552a..88ea9e7a9d 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -1,9 +1,14 @@ +import type { DisclosureTriggerEdge } from "./strategy"; + interface RowGeometry { id: string; top: number; height: number; } +const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) => + edge === "top" ? row.top : row.top + row.height; + // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; export interface ReadingRowReplacements { @@ -14,7 +19,16 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. export function createReadingAnchor() { - let anchor: { id: string; top: number } | null = null; + // The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on, + // with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the + // offset before the next reconcile reads it. + let anchor: { + id: string; + edge: DisclosureTriggerEdge; + position: number; + pinnedScrollTop?: number; + } | null = null; + const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top }); let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); @@ -22,9 +36,12 @@ export function createReadingAnchor() { anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; const project = ( scrollTop: number, - row: Pick | undefined, + row: RowGeometry | undefined, replacements = NO_REPLACEMENTS, - ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); + ) => + anchor && row && row.id === getRowId(replacements) + ? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position + : scrollTop; return { getRowId, getReadingRowId: (scrollTop: number) => @@ -33,14 +50,14 @@ export function createReadingAnchor() { reset() { anchor = null; }, - // A disclosure names the row that owns its trigger. That row keeps its top - // through the toggle, even when another row sits under the reading line. - pin(row: Pick) { - anchor = { id: row.id, top: row.top }; + // A disclosure names the row that owns its trigger and the edge the trigger sits on. That + // edge stays in place through the toggle, even when another row sits under the reading line. + pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) { + anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop }; }, scroll(scrollTop: number) { const next = readingRow(scrollTop); - anchor = next ? { id: next.id, top: next.top } : null; + anchor = next ? anchorTop(next) : null; }, reconcile( scrollTop: number, @@ -57,7 +74,7 @@ export function createReadingAnchor() { // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); - anchor = next ? { id: next.id, top: next.top } : null; + anchor = next ? anchorTop(next) : null; return correctedTop; }, }; diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index ef855902d6..106c9c3bb4 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -35,10 +35,20 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; -import { createNativeDisclosureHold } from "./native-disclosure-hold"; +import { + createNativeDisclosureHold, + disclosureAnchor, + createEndHoldDistance, + type EndHoldDistance, +} from "./native-disclosure-hold"; import { EndPinnedScrollView } from "./native-end-pin"; import { createNewRowEstimates } from "./new-row-estimates"; -import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; +import type { + DisclosureTriggerEdge, + StreamRenderInput, + StreamStrategy, + StreamViewportHandle, +} from "./strategy"; import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy"; import { isHistoryStartLoadingOperation, @@ -306,8 +316,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const isAtEnd = useStableEvent( () => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX, ); + // A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the + // tracked distance instead of the offset: the content size reaches JS before the pin's scroll does. + const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null); const releaseDisclosure = useStableEvent(() => { - bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() }); + setEndHold(null); + bottomAnchorController.releaseDisclosure({ + isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(), + }); }); const [disclosureHold] = useState(() => createNativeDisclosureHold({ @@ -317,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onRelease: releaseDisclosure, }), ); - useEffect(() => () => disclosureHold.cancel(), [disclosureHold]); - // The hold detaches the controller in the same event as `apply`, so the toggle's commit already - // has end following off and LegendList anchoring on the held row. The row's measurement trigger - // makes that same commit carry the row's new height. - const holdRow = useStableEvent((rowId: string, apply: () => void) => { - disclosureHold.hold(rowId); - bottomAnchorController.holdForDisclosure(); - apply(); - rowLayoutSyncRegistry.get(rowId)?.(); + const cancelDisclosureHold = useStableEvent(() => { + disclosureHold.cancel(); + setEndHold(null); }); + useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]); + // The hold detaches the controller in the same event as `apply`, so the toggle's commit already + // has end following off and LegendList anchoring on the held row, or on the row below it for a + // trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry + // the row's new height. While following, a bottom trigger keeps following: the end pin holds the + // content end, and with it the bottom of the row, in the frame the new height mounts. + // The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from + // any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing. + // So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers. + const latestDisclosureAnchor = useStableEvent( + (rowId: string, triggerEdge: DisclosureTriggerEdge) => + disclosureAnchor(rows, rowId, triggerEdge), + ); + const holdRow = useStableEvent( + (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => { + const applyAndMeasure = () => { + apply(); + rowLayoutSyncRegistry.get(rowId)?.(); + }; + if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") { + applyAndMeasure(); + return; + } + const anchor = disclosureAnchor(rows, rowId, triggerEdge); + bottomAnchorController.holdForDisclosure(); + if (anchor.kind === "row" || !isListPlacedRef.current) { + setEndHold(null); + disclosureHold.hold(anchor); + applyAndMeasure(); + return; + } + setEndHold({ + rowId, + distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)), + }); + disclosureHold.holdNextFrame( + () => latestDisclosureAnchor(rowId, triggerEdge), + (latestAnchor) => { + if (latestAnchor.kind === "row") setEndHold(null); + applyAndMeasure(); + }, + ); + }, + ); const handleItemSizeChanged = useStableEvent( (info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => { newRowEstimates.noteMeasured(info.itemData, info.size); @@ -334,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }, ); const shouldRestorePosition = useStableEvent((item: StreamItem) => { - const heldRowId = disclosureHold.heldRowId(); - return heldRowId === null || item.id === heldRowId; + const anchorRowId = disclosureHold.anchorRowId(); + return anchorRowId === null || item.id === anchorRowId; }); // One owner keeps the scroll offset at any moment: // - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It @@ -349,16 +403,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands // short of the real end and the controller reads that as the user scrolling away. // - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and - // LegendList keeps only the held row in place. + // LegendList keeps only the held row in place, or the row below it when the trigger sits on the + // held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and + // one on the last row mounts the pin for the hold: the row's bottom is the content end. // - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below // the rows the user is dragging, and resumes following if the gesture ends at the end. // - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading // above or rows growing below leave what the user is reading where it is. const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom"; - const pinsEnd = isFollowingEnd && isListPlaced; + // An end hold pins the end only while its row's bottom is the end. A reply landing below the row + // drops the pin in the commit that mounts the reply, so the reply grows below the held row. + const isEndHeld = + endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end"; + const isEndPinned = isFollowingEnd || isEndHeld; + const pinsEnd = isEndPinned && isListPlaced; const maintainVisibleContentPosition = useMemo( - () => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }), - [isFollowingEnd, shouldRestorePosition], + () => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }), + [isEndPinned, shouldRestorePosition], ); // A new identity re-renders LegendList's scroll component, which is how the pin follows the mode. const renderScrollComponent = useCallback( @@ -505,7 +566,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can // keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave // no row LegendList may anchor a history page on. The gesture's end decides following. - disclosureHold.cancel(); + cancelDisclosureHold(); bottomAnchorController.beginUserScroll(); historyStartPagination.rearm(); }); @@ -581,6 +642,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null, }; + endHold?.distance.noteContentSize(streamViewportMetricsRef.current); bottomAnchorController.handleContentSizeChange(); // Content growing below a detached viewport moves the end away without a scroll event. if ( diff --git a/packages/app/src/agent-stream/strategy-web.browser.test.tsx b/packages/app/src/agent-stream/strategy-web.browser.test.tsx index 24e8db7e1a..12127ea630 100644 --- a/packages/app/src/agent-stream/strategy-web.browser.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.browser.test.tsx @@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import type { StreamItem } from "@/types/stream"; -import type { StreamRenderInput, StreamViewportHandle } from "./strategy"; +import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy"; import { createWebStreamStrategy } from "./strategy-web"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) { return { render: (segments: Partial) => act(() => render(segments)), - hold: (rowId: string, apply: () => void) => - act(() => viewportRef.current!.holdRow(rowId, apply)), + hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") => + act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)), rowTop: (id: string) => rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + rowBottom: (id: string) => + rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top, + scrollTo: (top: number) => { + scrollContainer.scrollTop = top; + }, distanceFromEnd: () => scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop, // Resize observers and the hold's settle run on animation frames. @@ -139,6 +144,53 @@ describe("web disclosure hold", () => { expect(timeline.rowTop("message-5")).toBe(triggerTop); }); + // A long message's show more sits under the text it opens, on the bottom edge of its row. + it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + const triggerBottom = timeline.rowBottom("message-5"); + + timeline.hold( + "message-5", + () => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }), + "bottom", + ); + await timeline.settle(); + expect(timeline.rowBottom("message-5")).toBe(triggerBottom); + + timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom"); + await timeline.settle(); + expect(timeline.rowBottom("message-5")).toBe(triggerBottom); + }); + + it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => { + const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); + const timeline = mountTimeline(history); + await timeline.settle(); + timeline.scrollTo(60); + await timeline.settle(); + // message-2 is cut off at the top; message-3 is fully in view. + for (const index of [2, 3]) { + const triggerBottom = timeline.rowBottom(`message-${index}`); + const grown = history.map((item, position) => + position === index - 1 ? row(index, 240) : item, + ); + + timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom"); + await timeline.settle(); + expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom); + + timeline.hold( + `message-${index}`, + () => timeline.render({ historyMounted: history }), + "bottom", + ); + await timeline.settle(); + expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom); + } + }); + it("resumes following when collapsing an anchored row lands back at the end", async () => { const history = [1, 2, 3, 4, 5, 6].map((index) => row(index)); const timeline = mountTimeline(history); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 69867fb60d..c47e0720f4 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -418,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool { id: String(measurement.key), top: measurement.start, + height: measurement.size, }, props.readingRowReplacements, ) @@ -1172,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, - holdRow: (rowId, apply) => { + holdRow: (rowId, apply, triggerEdge) => { cancelPendingStickToBottom(); setFollowOutput(false); const row = measureRows()?.find((candidate) => candidate.id === rowId); - if (row) readingAnchor.pin(row); + const scrollTop = scrollContainerRef.current?.scrollTop; + if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop); disclosurePendingRef.current = true; apply(); }, diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 7f10448543..aab5b0413e 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -46,16 +46,23 @@ export interface ScrollToMessageOccurrence { targetTop(): number | null; } +/** + * The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content + * it opens grows below it. A trigger under the content it opens, such as a long message's show + * more, sits on the bottom. + */ +export type DisclosureTriggerEdge = "top" | "bottom"; + export interface StreamViewportHandle { /** Give keyboard navigation back to the scrolling surface on web. */ focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; /** - * Run a disclosure's state change so the row that owns its trigger keeps its visual top. - * Following output stops; it resumes if the change leaves the list at its end. + * Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger + * sits on in place. Following output stops; it resumes if the change leaves the list at its end. */ - holdRow: (rowId: string, apply: () => void) => void; + holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; } diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index c0456f524d..65aaa96e92 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -1,3 +1,4 @@ +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -50,7 +51,8 @@ function PlanCardContent({ () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), [expanded], ); - const toggleExpanded = useCallback(() => setExpanded((value) => !value), []); + const hold = useDisclosureHold(); + const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]); const containerStyle = useMemo( () => [styles.container, disableOuterSpacing && styles.containerCompact], [disableOuterSpacing], diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx index e7bef2d177..dc73c517ca 100644 --- a/packages/app/src/components/user-message/index.tsx +++ b/packages/app/src/components/user-message/index.tsx @@ -9,8 +9,11 @@ import { useProviderIcon } from "@/components/provider-icons"; import { useSessionStore } from "@/stores/session-store"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { isNative } from "@/constants/platform"; +import { useDisclosureHold } from "@/agent-stream/disclosure"; const PREVIEW_HEIGHT = 240; +// Show more and show less sit under the text they open and close. +const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const; // Native View still clips with "scroll", but Yoga measures its children without // the height cap. "hidden" caps the measured body too, hiding the expand control. const previewStyle: ViewStyle = { @@ -26,7 +29,11 @@ export function MessagePreview({ children }: { children: ReactNode }) { (event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT), [], ); - const toggle = useCallback(() => setExpanded((value) => !value), []); + const hold = useDisclosureHold(); + const toggle = useCallback( + () => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT), + [hold], + ); const accessibilityState = useMemo(() => ({ expanded }), [expanded]); return ( <> diff --git a/patches/@legendapp+list+3.7.1.patch b/patches/@legendapp+list+3.7.1.patch index b232494bf5..936589394f 100644 --- a/patches/@legendapp+list+3.7.1.patch +++ b/patches/@legendapp+list+3.7.1.patch @@ -19,15 +19,21 @@ # 7. Header and footer revealed with the rows (RevealedLayoutView, ListComponent). # 8. Top-padding shrink hold on iOS only (setPaddingTop): Android paints the held height as blank # space at the end. -# 9. Rows cut off at the top count as in view (getIdsInVisibleRange): they follow the fully -# visible rows in idsInView, so a disclosure hold on a row whose top is above the viewport -# still anchors on that row instead of the row below it. +# 9. Rows cut off at the top count as in view, and so does the row just past the end of the +# viewport (getIdsInVisibleRange): they follow the fully visible rows in idsInView, so a +# disclosure hold on a row whose top is above the viewport still anchors on that row instead +# of the row below it, and a hold on the row below a bottom-edge trigger anchors even when +# that row starts below the viewport. # 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a # data change already does, so a disclosure that only grows (a todo card) holds the cut-off # row instead of the first row in view. # 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab # (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row # whose frame was unchanged when the tab showed again got no layout event to correct it. +# 12. No Android clamp prediction for a size change (prepareMVCP): upstream measures the adjustment +# of a shrink past the end from the new end, assuming the scroll view clamped first. The app's +# scroll view clamps after the adjustment on both platforms (modules/paseo-scroll), so a +# bottom-edge disclosure collapsing near the end landed at the end instead of in place. # # Upgrading: drop each hunk whose fix the new version ships, regenerate with # `npx patch-package @legendapp/list`, and copy this header back on top. @@ -48,7 +54,7 @@ index 983e937..970ba51 100644 * Returns a stable item type used for pooling and size estimation. */ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index eeb3fb0..5fe940d 100644 +index eeb3fb0..5ebd8e9 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js @@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no @@ -135,7 +141,28 @@ index eeb3fb0..5fe940d 100644 } if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { for (let i = 0; i < idsInView.length; i++) { -@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) { +@@ -1874,15 +1897,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } +- } ++ // Paseo patch: no Android clamp prediction. Upstream assumes the scroll view clamped to the new ++ // end before React Native adds the anchor's movement, and measures the adjustment from that ++ // end. The app's scroll view defers the clamp until after the adjustment, as iOS does ++ // (modules/paseo-scroll), so an adjustment measured from the end moved a shrinking row's ++ // anchor back to the end instead of keeping it in place. + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -2371,7 +2390,10 @@ function finishScrollTo(ctx) { }; if (scrollingTo.isInitialScroll || state.initialScroll) { const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; @@ -147,7 +174,7 @@ index eeb3fb0..5fe940d 100644 finishInitialScroll(ctx, { onFinished: complete, preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, -@@ -2515,11 +2541,12 @@ function checkFinishedScrollFallback(ctx) { +@@ -2515,11 +2537,12 @@ function checkFinishedScrollFallback(ctx) { const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; initialScrollCompletion.markSilentInitialScrollRetry(state); @@ -164,7 +191,7 @@ index eeb3fb0..5fe940d 100644 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { +@@ -3378,7 +3401,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { var _a3; const state = ctx.state; clearBootstrapInitialScrollSession(state); @@ -174,7 +201,7 @@ index eeb3fb0..5fe940d 100644 finishInitialScroll(ctx, { preserveTarget: shouldPreserveResizeTarget, recalculateItems: true, -@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) { +@@ -4569,6 +4593,24 @@ function getIdsInVisibleRange(state, range) { const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); idsInView.push(id); } @@ -188,11 +215,18 @@ index eeb3fb0..5fe940d 100644 + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ } ++ // Paseo patch: the row that starts at or below the end of the viewport goes last. A disclosure ++ // whose trigger sits on the bottom edge of its row holds the row below it, whose top is that ++ // edge, and that row is below the viewport whenever the held row's bottom is. ++ const belowIndex = range.endNoBuffer + 1; ++ if (belowIndex < state.props.data.length) { ++ idsInView.push((_b = state.idCache[belowIndex]) != null ? _b : getId(state, belowIndex)); + } } return idsInView; } -@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL +@@ -5176,8 +5218,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL function isWithinEpsilon(delta) { return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; } @@ -208,7 +242,7 @@ index eeb3fb0..5fe940d 100644 } // src/core/updateItemSizes.ts -@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) { +@@ -5291,6 +5338,11 @@ function updateItemSizesBatch(ctx, measurements) { const result = {}; const previousTotalSize = state.totalSize; for (const measurement of measurements) { @@ -220,7 +254,7 @@ index eeb3fb0..5fe940d 100644 const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey); if (ownsMeasuredItem) { const index = state.indexByKey.get(measurement.itemKey); -@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -5414,7 +5466,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif state.fixedItemSizeKeys.add(itemKey); } const prevSizeKnown = sizesKnown.get(itemKey); @@ -229,7 +263,7 @@ index eeb3fb0..5fe940d 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6283,6 +6335,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -240,7 +274,7 @@ index eeb3fb0..5fe940d 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6290,7 +6346,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -249,7 +283,7 @@ index eeb3fb0..5fe940d 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6513,6 +6569,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -264,7 +298,7 @@ index eeb3fb0..5fe940d 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6651,7 +6715,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null), @@ -273,7 +307,7 @@ index eeb3fb0..5fe940d 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement( -@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6665,7 +6729,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -282,7 +316,7 @@ index eeb3fb0..5fe940d 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7886,7 +7950,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -294,7 +328,7 @@ index eeb3fb0..5fe940d 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7989,6 +8056,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -302,7 +336,7 @@ index eeb3fb0..5fe940d 100644 getItemType, horizontal, rtl, -@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8282,6 +8350,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize, @@ -311,7 +345,7 @@ index eeb3fb0..5fe940d 100644 getItemType: useWrapIfItem(getItemType), hasExternalScroll, diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index fe5f415..7f1b9dd 100644 +index fe5f415..9788ce6 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs @@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no @@ -398,7 +432,28 @@ index fe5f415..7f1b9dd 100644 } if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { for (let i = 0; i < idsInView.length; i++) { -@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) { +@@ -1853,15 +1876,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } +- } ++ // Paseo patch: no Android clamp prediction. Upstream assumes the scroll view clamped to the new ++ // end before React Native adds the anchor's movement, and measures the adjustment from that ++ // end. The app's scroll view defers the clamp until after the adjustment, as iOS does ++ // (modules/paseo-scroll), so an adjustment measured from the end moved a shrinking row's ++ // anchor back to the end instead of keeping it in place. + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -2350,7 +2369,10 @@ function finishScrollTo(ctx) { }; if (scrollingTo.isInitialScroll || state.initialScroll) { const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; @@ -410,7 +465,7 @@ index fe5f415..7f1b9dd 100644 finishInitialScroll(ctx, { onFinished: complete, preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, -@@ -2494,11 +2520,12 @@ function checkFinishedScrollFallback(ctx) { +@@ -2494,11 +2516,12 @@ function checkFinishedScrollFallback(ctx) { const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; initialScrollCompletion.markSilentInitialScrollRetry(state); @@ -427,7 +482,7 @@ index fe5f415..7f1b9dd 100644 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { +@@ -3357,7 +3380,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { var _a3; const state = ctx.state; clearBootstrapInitialScrollSession(state); @@ -437,7 +492,7 @@ index fe5f415..7f1b9dd 100644 finishInitialScroll(ctx, { preserveTarget: shouldPreserveResizeTarget, recalculateItems: true, -@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) { +@@ -4548,6 +4572,24 @@ function getIdsInVisibleRange(state, range) { const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); idsInView.push(id); } @@ -451,11 +506,18 @@ index fe5f415..7f1b9dd 100644 + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ } ++ // Paseo patch: the row that starts at or below the end of the viewport goes last. A disclosure ++ // whose trigger sits on the bottom edge of its row holds the row below it, whose top is that ++ // edge, and that row is below the viewport whenever the held row's bottom is. ++ const belowIndex = range.endNoBuffer + 1; ++ if (belowIndex < state.props.data.length) { ++ idsInView.push((_b = state.idCache[belowIndex]) != null ? _b : getId(state, belowIndex)); + } } return idsInView; } -@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL +@@ -5155,8 +5197,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL function isWithinEpsilon(delta) { return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; } @@ -471,7 +533,7 @@ index fe5f415..7f1b9dd 100644 } // src/core/updateItemSizes.ts -@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) { +@@ -5270,6 +5317,11 @@ function updateItemSizesBatch(ctx, measurements) { const result = {}; const previousTotalSize = state.totalSize; for (const measurement of measurements) { @@ -483,7 +545,7 @@ index fe5f415..7f1b9dd 100644 const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey); if (ownsMeasuredItem) { const index = state.indexByKey.get(measurement.itemKey); -@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -5393,7 +5445,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif state.fixedItemSizeKeys.add(itemKey); } const prevSizeKnown = sizesKnown.get(itemKey); @@ -492,7 +554,7 @@ index fe5f415..7f1b9dd 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6262,6 +6314,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -503,7 +565,7 @@ index fe5f415..7f1b9dd 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6269,7 +6325,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -512,7 +574,7 @@ index fe5f415..7f1b9dd 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6492,6 +6548,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -527,7 +589,7 @@ index fe5f415..7f1b9dd 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6630,7 +6694,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2.createElement(ScrollAdjust, null), @@ -536,7 +598,7 @@ index fe5f415..7f1b9dd 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement( -@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6644,7 +6708,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -545,7 +607,7 @@ index fe5f415..7f1b9dd 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7865,7 +7929,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -557,7 +619,7 @@ index fe5f415..7f1b9dd 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7968,6 +8035,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -565,7 +627,7 @@ index fe5f415..7f1b9dd 100644 getItemType, horizontal, rtl, -@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8261,6 +8329,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize,