mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
* fix(app): keep show more and plan card triggers in place on expand Long user message previews and plan cards toggled with a plain setState, skipping the timeline's disclosure hold. On Android the expanded content painted over the next row for a frame before the list learned the new size and corrected the scroll. Route both toggles through useDisclosureHold like the other timeline disclosures. * fix(app): keep show more at the same y by holding the message's bottom edge Show more sits under the text it opens, so a top-anchored hold pushed it off screen. The disclosure hold takes a trigger edge; MessagePreview passes the bottom edge. Web pins that edge in the reading anchor, native anchors the row below the message, and while following the end pin keeps the bottom in place. The LegendList patch counts the row just past the viewport as in view and drops the Android clamp prediction that moved a collapse near the end to the end. * fix(app): keep show more in place on an unanswered final prompt The last row has no row below it to anchor its bottom on, so a show more on a prompt the agent has not answered yet moved the trigger off screen. The hold now mounts the end pin while detached and toggles a frame later, once the pin anchors, and re-sticks only if the list was at its end when the toggle was tapped. The disclosure browser test now renders the real Markdown renderer. * fix(app): resume following when a final prompt collapse reaches the end An end hold decided its release from whether the list was at its end when tapped. A collapse that leaves less scroll room than that distance ends at the end, so the list stayed detached and later output grew below the viewport. The release now clamps the tapped distance to the room left. A row hold also clears an end hold still in progress, so the plan card or another toggle anchors its own row. * fix(app): keep following when a reply lands during a final prompt collapse The end hold clamped its distance from the end only against the final content size. A reply that grew the content after a collapse reached the end brought the old distance back, so the hold released detached. The hold now clamps on every content size change. * fix(app): let a reply grow below a held final prompt An end hold pinned the content end for the whole hold, so a reply that landed during it scrolled the toggled prompt up. The pin now holds only while the toggled row is the last row, and drops in the commit that mounts the reply. * fix(app): move a waiting final prompt hold to a reply that lands first The end hold's toggle waits a frame for the end pin, and it kept the anchor from the tap. A reply landing in that frame dropped the pin but left the hold with no row, so the toggle moved the button. The disclosure hold now owns the waiting toggle and resolves the anchor when the frame runs.
198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# 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, 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
|
|
|
|
A disclosure component knows nothing about the list. It wraps its state change:
|
|
|
|
```tsx
|
|
const hold = useDisclosureHold();
|
|
const handleToggle = () => hold(() => setExpanded((value) => !value));
|
|
```
|
|
|
|
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
|
`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 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
|
|
following.
|
|
- Scroll events during the hold never re-stick: the correction is a programmatic scroll that
|
|
would otherwise read as "back at the bottom".
|
|
|
|
The trigger can only stay put when the list has scroll room for the change. When the loaded
|
|
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
|
ends at its end and the trigger moves by the difference.
|
|
|
|
## Web
|
|
|
|
`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, 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
|
|
change in the commit that makes it, so a hold needs no anchor neighbour, native child index or
|
|
render window stretch. The inverted FlatList it replaced needed all three, and three commits per
|
|
toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
|
|
|
### Who owns the offset
|
|
|
|
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`), 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
|
|
native correction runs on the UI thread around each mount, so a row that grows or lands at the end
|
|
scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer
|
|
growing or shrinking) and an explicit jump, both through the native `scrollToEnd`.
|
|
|
|
Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end
|
|
its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of
|
|
the native end, which the controller reads as the user scrolling away.
|
|
|
|
The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list
|
|
and the composer together on the UI thread, and the composer viewport clips the top of the list
|
|
while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can
|
|
only commit after the motion, and while detached or holding nothing corrects for it, so every row
|
|
jumps by the keyboard height after the keyboard settles.
|
|
|
|
### The hold
|
|
|
|
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
|
|
|
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, 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
|
|
later, after the change painted. The trigger puts the measurement and the corrected positions
|
|
in the toggle's own commit.
|
|
|
|
The hold ends two frames after the last item size change, since the list measures the toggled row
|
|
and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then
|
|
re-sticks only if the list is within 1 px of its end, the same distance web resumes from.
|
|
|
|
### User scroll
|
|
|
|
The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below
|
|
the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for
|
|
those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while
|
|
the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's:
|
|
in the detached state LegendList uses the same native correction, and a history page that lands
|
|
mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following
|
|
keeps following.
|
|
|
|
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
|
|
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
|
|
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
|
|
with the scroll-to-bottom pill, so the rows the user let go of never move.
|
|
|
|
### Draw distance
|
|
|
|
`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured
|
|
blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a
|
|
15,000 px turn expanded, Android emulator, dev build:
|
|
|
|
| List | Blank viewport | Janky frames | p50 / p90 / p99 |
|
|
| ----------------------------- | -------------------------- | ------------ | ---------------- |
|
|
| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms |
|
|
| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | |
|
|
| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms |
|
|
|
|
With every turn collapsed, flinging from a cold open into history did not blank at any value from
|
|
500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure
|
|
1000 and 1500 on the expanded chat, on a host under load 10, before lowering it.
|
|
|
|
### LegendList patch
|
|
|
|
`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.
|
|
|
|
### Scroll view corrections
|
|
|
|
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
|
|
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
|
|
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
|
|
|
|
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
|
|
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
|
|
the mount, and React Native then adds the anchor's movement to the clamped offset
|
|
([RN #58578](https://github.com/react/react-native/issues/58578), fix in
|
|
[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list
|
|
follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores
|
|
the pre-mount offset for the adjustment and clamps after it.
|
|
|
|
**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped
|
|
disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS
|
|
subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already.
|
|
React Native 0.88 skips the adjustment for a removed anchor
|
|
([PR #57294](https://github.com/react/react-native/pull/57294)).
|
|
|
|
**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`,
|
|
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
|
Native counterpart; it stays while the timeline uses the pin.
|
|
|
|
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
|
their fixes.
|